[Security Assistant] Move security AI assistant button into global nav bar (#203060)

## Summary

More changes are needed within the observability and search solution to
close the issue fully.

Summarise your PR. If it involves visual changes include a screenshot or
gif.

Move the security AI assistant button from the solution header bar into
the global nav bar. This is part of the AI assistant unification
initiative.

### How to Test
- Start kibana
- Go to one of the security solution pages (e.g. attack discovery)
- AI assistant button should be in the global nav bar. Clicking it opens
the assistant.

- The button can also be tested for security serverless deployment. It
should look like the screenshot bellow.

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...

Classic:

![image](https://github.com/user-attachments/assets/b2a9c982-bc54-42f4-ab59-6f0c99d4d899)

![image](https://github.com/user-attachments/assets/1ae36af0-5d1a-4519-844a-563074646ddf)

Serverless:

![image](https://github.com/user-attachments/assets/345280df-0e70-4203-b0d8-48ad11753f74)

![image](https://github.com/user-attachments/assets/7425c886-4528-4987-a00a-48bdc71728c7)

Old:
<img width="1728" alt="image"
src="https://github.com/user-attachments/assets/5ef568c6-2d31-47da-8f5f-87dfdf10cb5c">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Kenneth Kreindler 2025-01-08 11:03:54 +01:00 committed by GitHub
parent 4873fa18d7
commit 06cf554981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 363 additions and 137 deletions

View file

@ -15,6 +15,8 @@ import { DEFAULT_CONVERSATION_TITLE } from '../../use_conversation/translations'
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { WELCOME_CONVERSATION } from '../../use_conversation/sample_conversations';
import { PromptResponse } from '@kbn/elastic-assistant-common';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
const BASE_CONVERSATION: Conversation = {
...WELCOME_CONVERSATION,
@ -36,6 +38,13 @@ const mockUseAssistantContext = {
setConversations: jest.fn(),
setAllSystemPrompts: jest.fn(),
allSystemPrompts: mockSystemPrompts,
chrome: {
getChromeStyle$: jest.fn(() => of('classic')),
navControls: chromeServiceMock.createStartContract().navControls,
},
assistantAvailability: {
hasAssistantPrivilege: true,
},
};
jest.mock('../../../assistant_context', () => {

View file

@ -11,6 +11,8 @@ import { QuickPromptSettings } from './quick_prompt_settings';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { MOCK_QUICK_PROMPTS } from '../../../mock/quick_prompt';
import { mockPromptContexts } from '../../../mock/prompt_context';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
const onSelectedQuickPromptChange = jest.fn();
const setPromptsBulkActions = jest.fn();
@ -28,6 +30,13 @@ const testProps = {
};
const mockContext = {
basePromptContexts: MOCK_QUICK_PROMPTS,
chrome: {
getChromeStyle$: jest.fn(() => of('classic')),
navControls: chromeServiceMock.createStartContract().navControls,
},
assistantAvailability: {
hasAssistantPrivilege: true,
},
};
jest.mock('../../../assistant_context', () => ({

View file

@ -11,6 +11,8 @@ import { TestProviders } from '../../mock/test_providers/test_providers';
import { MOCK_QUICK_PROMPTS } from '../../mock/quick_prompt';
import { QUICK_PROMPTS_TAB } from '../settings/const';
import { QuickPrompts } from './quick_prompts';
import { of } from 'rxjs';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
const setInput = jest.fn();
const setIsSettingsModalVisible = jest.fn();
@ -26,6 +28,13 @@ const mockUseAssistantContext = {
setSelectedSettingsTab,
promptContexts: {},
allQuickPrompts: MOCK_QUICK_PROMPTS,
chrome: {
getChromeStyle$: jest.fn(() => of('classic')),
navControls: chromeServiceMock.createStartContract().navControls,
},
assistantAvailability: {
hasAssistantPrivilege: true,
},
};
const testTitle = 'SPL_QUERY_CONVERSION_TITLE';

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, renderHook } from '@testing-library/react';
import { AssistantNavLink } from './assistant_nav_link';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { ChromeNavControl } from '@kbn/core/public';
import { createHtmlPortalNode, OutPortal } from 'react-reverse-portal';
import { of } from 'rxjs';
import { useAssistantContext } from '.';
const MockNavigationBar = OutPortal;
const mockShowAssistantOverlay = jest.fn();
const mockNavControls = chromeServiceMock.createStartContract().navControls;
const mockGetChromeStyle = jest.fn();
const mockAssistantContext = {
chrome: {
getChromeStyle$: mockGetChromeStyle,
navControls: mockNavControls,
},
showAssistantOverlay: mockShowAssistantOverlay,
assistantAvailability: {
hasAssistantPrivilege: true,
},
};
jest.mock('.', () => {
return {
...jest.requireActual('.'),
useAssistantContext: jest.fn(),
};
});
describe('AssistantNavLink', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetChromeStyle.mockReturnValue(of('classic'));
(useAssistantContext as jest.Mock).mockReturnValue({
...mockAssistantContext,
});
});
it('should register link in nav bar', () => {
render(<AssistantNavLink />);
expect(mockNavControls.registerRight).toHaveBeenCalledTimes(1);
});
it('button has transparent background in project navigation', () => {
const { result: portalNode } = renderHook(() =>
React.useMemo(() => createHtmlPortalNode(), [])
);
mockGetChromeStyle.mockReturnValue(of('project'));
mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => {
chromeNavControl.mount(portalNode.current.element);
});
const { queryByTestId } = render(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
expect(queryByTestId('assistantNavLink')).not.toHaveStyle(
'background-color: rgb(204, 228, 245)'
);
});
it('button has opaque background in classic navigation', () => {
const { result: portalNode } = renderHook(() =>
React.useMemo(() => createHtmlPortalNode(), [])
);
mockGetChromeStyle.mockReturnValue(of('classic'));
mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => {
chromeNavControl.mount(portalNode.current.element);
});
const { queryByTestId } = render(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
expect(queryByTestId('assistantNavLink')).toHaveStyle('background-color: rgb(204, 228, 245)');
});
it('should render the header link text', () => {
const { result: portalNode } = renderHook(() =>
React.useMemo(() => createHtmlPortalNode(), [])
);
mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => {
chromeNavControl.mount(portalNode.current.element);
});
const { queryByText, queryByTestId } = render(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
expect(queryByTestId('assistantNavLink')).toBeInTheDocument();
expect(queryByText('AI Assistant')).toBeInTheDocument();
});
it('should not render the header link if not authorized', () => {
const { result: portalNode } = renderHook(() =>
React.useMemo(() => createHtmlPortalNode(), [])
);
mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => {
chromeNavControl.mount(portalNode.current.element);
});
(useAssistantContext as jest.Mock).mockReturnValue({
...mockAssistantContext,
assistantAvailability: {
hasAssistantPrivilege: false,
},
});
const { queryByText, queryByTestId } = render(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
expect(queryByTestId('assistantNavLink')).not.toBeInTheDocument();
expect(queryByText('AI Assistant')).not.toBeInTheDocument();
});
it('should call the assistant overlay to show on click', () => {
const { result: portalNode } = renderHook(() =>
React.useMemo(() => createHtmlPortalNode(), [])
);
mockNavControls.registerRight.mockImplementation((chromeNavControl: ChromeNavControl) => {
chromeNavControl.mount(portalNode.current.element);
});
const { queryByTestId } = render(
<>
<MockNavigationBar node={portalNode.current} />
<AssistantNavLink />
</>
);
queryByTestId('assistantNavLink')?.click();
expect(mockShowAssistantOverlay).toHaveBeenCalledTimes(1);
expect(mockShowAssistantOverlay).toHaveBeenCalledWith({ showOverlay: true });
});
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { createHtmlPortalNode, OutPortal, InPortal } from 'react-reverse-portal';
import { EuiToolTip, EuiButton, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ChromeStyle } from '@kbn/core-chrome-browser';
import { AssistantIcon } from '@kbn/ai-assistant-icon';
import { useAssistantContext } from '.';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const TOOLTIP_CONTENT = i18n.translate(
'xpack.elasticAssistant.assistantContext.assistantNavLinkShortcutTooltip',
{
values: { keyboardShortcut: isMac ? '⌘ ;' : 'Ctrl ;' },
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
}
);
const LINK_LABEL = i18n.translate('xpack.elasticAssistant.assistantContext.assistantNavLink', {
defaultMessage: 'AI Assistant',
});
export const AssistantNavLink: FC = () => {
const { chrome, showAssistantOverlay, assistantAvailability, currentAppId } =
useAssistantContext();
const portalNode = React.useMemo(() => createHtmlPortalNode(), []);
const [chromeStyle, setChromeStyle] = useState<ChromeStyle | undefined>(undefined);
// useObserverable would change the order of re-renders that are tested against closely.
useEffect(() => {
const s = chrome.getChromeStyle$().subscribe(setChromeStyle);
return () => s.unsubscribe();
}, [chrome]);
useEffect(() => {
const registerPortalNode = () => {
chrome.navControls.registerRight({
mount: (element: HTMLElement) => {
ReactDOM.render(<OutPortal node={portalNode} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
},
// right before the user profile
order: 1001,
});
};
if (
assistantAvailability.hasAssistantPrivilege &&
chromeStyle &&
currentAppId !== 'management'
) {
registerPortalNode();
}
}, [chrome, portalNode, assistantAvailability.hasAssistantPrivilege, chromeStyle, currentAppId]);
const showOverlay = useCallback(
() => showAssistantOverlay({ showOverlay: true }),
[showAssistantOverlay]
);
if (!assistantAvailability.hasAssistantPrivilege || !chromeStyle) {
return null;
}
const EuiButtonBasicOrEmpty = chromeStyle === 'project' ? EuiButtonEmpty : EuiButton;
return (
<InPortal node={portalNode}>
<EuiToolTip content={TOOLTIP_CONTENT}>
<EuiButtonBasicOrEmpty
onClick={showOverlay}
color="primary"
size="s"
data-test-subj="assistantNavLink"
>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantIcon size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{LINK_LABEL}</EuiFlexItem>
</EuiFlexGroup>
</EuiButtonBasicOrEmpty>
</EuiToolTip>
</InPortal>
);
};

View file

@ -14,7 +14,7 @@ import useLocalStorage from 'react-use/lib/useLocalStorage';
import useSessionStorage from 'react-use/lib/useSessionStorage';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { NavigateToAppOptions, UserProfileService } from '@kbn/core/public';
import { ChromeStart, NavigateToAppOptions, UserProfileService } from '@kbn/core/public';
import { useQuery } from '@tanstack/react-query';
import { updatePromptContexts } from './helpers';
import type {
@ -43,6 +43,7 @@ import {
import { useCapabilities } from '../assistant/api/capabilities/use_capabilities';
import { WELCOME_CONVERSATION_TITLE } from '../assistant/use_conversation/translations';
import { SettingsTabs } from '../assistant/settings/types';
import { AssistantNavLink } from './assistant_nav_link';
export interface ShowAssistantOverlayProps {
showOverlay: boolean;
@ -77,6 +78,7 @@ export interface AssistantProviderProps {
toasts?: IToasts;
currentAppId: string;
userProfileService: UserProfileService;
chrome: ChromeStart;
}
export interface UserAvatar {
@ -128,6 +130,7 @@ export interface UseAssistantContext {
currentAppId: string;
codeBlockRef: React.MutableRefObject<(codeBlock: string) => void>;
userProfileService: UserProfileService;
chrome: ChromeStart;
}
const AssistantContext = React.createContext<UseAssistantContext | undefined>(undefined);
@ -151,6 +154,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
toasts,
currentAppId,
userProfileService,
chrome,
}) => {
/**
* Session storage for traceOptions, including APM URL and LangSmith Project/API Key
@ -303,6 +307,7 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
currentAppId,
codeBlockRef,
userProfileService,
chrome,
}),
[
actionTypeRegistry,
@ -338,10 +343,16 @@ export const AssistantProvider: React.FC<AssistantProviderProps> = ({
currentAppId,
codeBlockRef,
userProfileService,
chrome,
]
);
return <AssistantContext.Provider value={value}>{children}</AssistantContext.Provider>;
return (
<AssistantContext.Provider value={value}>
<AssistantNavLink />
{children}
</AssistantContext.Provider>
);
};
export const useAssistantContext = () => {

View file

@ -11,6 +11,8 @@ import { render } from '@testing-library/react';
import { TestProviders } from '../../../mock/test_providers/test_providers';
import { AnonymizationSettings } from '.';
import type { Props } from '.';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
const props: Props = {
anonymizationFields: {
@ -78,6 +80,7 @@ const mockUseAssistantContext = {
assistantAvailability: {
hasUpdateAIAssistantAnonymization: true,
hasManageGlobalKnowledgeBase: true,
hasAssistantPrivilege: true,
},
baseAllow: ['@timestamp', 'event.category', 'user.name'],
baseAllowReplacement: ['user.name', 'host.ip'],
@ -86,6 +89,10 @@ const mockUseAssistantContext = {
setAllSystemPrompts: jest.fn(),
setDefaultAllow: jest.fn(),
setDefaultAllowReplacement: jest.fn(),
chrome: {
getChromeStyle$: jest.fn(() => of('classic')),
navControls: chromeServiceMock.createStartContract().navControls,
},
};
jest.mock('../../../assistant_context', () => {
const original = jest.requireActual('../../../assistant_context');

View file

@ -14,6 +14,8 @@ import { TestProviders } from '../mock/test_providers/test_providers';
import { useKnowledgeBaseStatus } from '../assistant/api/knowledge_base/use_knowledge_base_status';
import { mockSystemPrompts } from '../mock/system_prompt';
import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
const mockUseAssistantContext = {
allSystemPrompts: mockSystemPrompts,
@ -28,6 +30,11 @@ const mockUseAssistantContext = {
setConversations: jest.fn(),
assistantAvailability: {
isAssistantEnabled: true,
hasAssistantPrivilege: true,
},
chrome: {
getChromeStyle$: jest.fn(() => of('classic')),
navControls: chromeServiceMock.createStartContract().navControls,
},
};

View file

@ -14,6 +14,8 @@ import { EuiThemeProvider as ThemeProvider } from '@elastic/eui';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { AssistantProvider, AssistantProviderProps } from '../../assistant_context';
import { AssistantAvailability } from '../../assistant_context/types';
@ -64,6 +66,9 @@ export const TestProvidersComponent: React.FC<Props> = ({
},
});
const chrome = chromeServiceMock.createStartContract();
chrome.getChromeStyle$.mockReturnValue(of('classic'));
return (
<I18nProvider>
<ThemeProvider>
@ -84,6 +89,7 @@ export const TestProvidersComponent: React.FC<Props> = ({
{...providerContext}
currentAppId={'test'}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}
>
{children}
</AssistantProvider>

View file

@ -34,6 +34,8 @@
"@kbn/zod",
"@kbn/data-views-plugin",
"@kbn/user-profile-components",
"@kbn/core-chrome-browser-mocks",
"@kbn/core-chrome-browser",
"@kbn/ai-assistant-icon",
]
}

View file

@ -40658,8 +40658,6 @@
"xpack.securitySolution.getStarted.landingCards.box.siemCard.title": "SIEM pour le centre opérationnel de sécurité moderne",
"xpack.securitySolution.getStarted.landingCards.box.unify.desc": "Elastic Security modernise les opérations de sécurité en facilitant l'analyse des données collectées au fil des ans, en automatisant les principaux processus et en protégeant chaque hôte.",
"xpack.securitySolution.getStarted.landingCards.box.unify.title": "Unification du SIEM, de la sécurité aux points de terminaison et de la sécurité cloud",
"xpack.securitySolution.globalHeader.assistantHeaderLink": "Assistant d'intelligence artificielle",
"xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip": "Raccourci clavier {keyboardShortcut}",
"xpack.securitySolution.globalHeader.buttonAddData": "Ajouter des intégrations",
"xpack.securitySolution.goToDocumentationButton": "Afficher la documentation",
"xpack.securitySolution.guideConfig.addDataStep.description": "Installez un agent sur l'un de vos ordinateurs et configurez-le avec l'intégration Elastic Defend. Grâce à cette intégration, l'agent pourra collecter et envoyer les données système à Elastic Security en temps réel.",

View file

@ -40515,8 +40515,6 @@
"xpack.securitySolution.getStarted.landingCards.box.siemCard.title": "最先端を行くSOCのSIEM",
"xpack.securitySolution.getStarted.landingCards.box.unify.desc": "Elasticセキュリティは数年分に及ぶデータの分析を可能にするほか、主要プロセスを自動化し、全ホストを保護して、最先端のセキュリティ運用を実現します。",
"xpack.securitySolution.getStarted.landingCards.box.unify.title": "SIEM、エンドポイントセキュリティ、クラウドセキュリティを一体化",
"xpack.securitySolution.globalHeader.assistantHeaderLink": "AI Assistant",
"xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip": "キーボードショートカット{keyboardShortcut}",
"xpack.securitySolution.globalHeader.buttonAddData": "統合の追加",
"xpack.securitySolution.goToDocumentationButton": "ドキュメンテーションを表示",
"xpack.securitySolution.guideConfig.addDataStep.description": "コンピューターのいずれかにエージェントをインストールし、Elastic Defend統合を構成します。この統合により、エージェントはシステムデータをリアルタイムで収集し、Elastic Securityに送信できます。",

View file

@ -39919,8 +39919,6 @@
"xpack.securitySolution.getStarted.landingCards.box.siemCard.title": "适用于现代 SOC 的 SIEM",
"xpack.securitySolution.getStarted.landingCards.box.unify.desc": "Elastic Security 实现了安全运营现代化,能够对多年的数据进行分析,自动执行关键流程,并保护每台主机。",
"xpack.securitySolution.getStarted.landingCards.box.unify.title": "集 SIEM、Endpoint Security 和云安全于一体",
"xpack.securitySolution.globalHeader.assistantHeaderLink": "AI 助手",
"xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip": "快捷键 {keyboardShortcut}",
"xpack.securitySolution.globalHeader.buttonAddData": "添加集成",
"xpack.securitySolution.goToDocumentationButton": "查看文档",
"xpack.securitySolution.guideConfig.addDataStep.description": "在您的计算机之一上安装代理,并使用 Elastic Defend 集成对其进行配置。使用此集成,该代理将能够实时收集系统数据并将其发送到 Elastic Security。",

View file

@ -15,6 +15,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Theme } from '@elastic/charts';
import { coreMock } from '@kbn/core/public/mocks';
import { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { I18nProvider } from '@kbn/i18n-react';
import { EuiThemeProvider } from '@elastic/eui';
@ -65,6 +67,8 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
error: () => {},
},
});
const chrome = chromeServiceMock.createStartContract();
chrome.getChromeStyle$.mockReturnValue(of('classic'));
return (
<KibanaRenderContextProvider {...coreMock.createStart()}>
@ -86,6 +90,7 @@ const TestExternalProvidersComponent: React.FC<TestExternalProvidersProps> = ({
navigateToApp={mockNavigateToApp}
currentAppId={'securitySolutionUI'}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}
>
{children}
</AssistantProvider>

View file

@ -22,6 +22,7 @@
"@kbn/core",
"@kbn/core-notifications-browser",
"@kbn/core-notifications-browser-mocks",
"@kbn/core-chrome-browser-mocks",
"@kbn/ai-assistant-icon",
"@kbn/react-kibana-context-render"
]

View file

@ -154,6 +154,6 @@ describe('global header', () => {
</TestProviders>
);
waitFor(() => expect(findByTestId('assistantHeaderLink')).toBeInTheDocument());
waitFor(() => expect(findByTestId('assistantNavLink')).toBeInTheDocument());
});
});

View file

@ -29,7 +29,6 @@ import {
showSourcererByPath,
} from '../../../sourcerer/containers/sourcerer_paths';
import { useAddIntegrationsUrl } from '../../../common/hooks/use_add_integrations_url';
import { AssistantHeaderLink } from '../../../assistant/header_link';
const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', {
defaultMessage: 'Add integrations',
@ -99,7 +98,6 @@ export const GlobalHeader = React.memo(() => {
{showSourcerer && !showTimeline && (
<Sourcerer scope={sourcererScope} data-test-subj="sourcerer" />
)}
<AssistantHeaderLink />
</EuiHeaderLinks>
</EuiHeaderSectionItem>
</EuiHeaderSection>

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { AssistantHeaderLink } from './header_link';
const mockShowAssistantOverlay = jest.fn();
const mockAssistantAvailability = jest.fn(() => ({
hasAssistantPrivilege: true,
}));
jest.mock('@kbn/elastic-assistant/impl/assistant_context', () => ({
useAssistantContext: () => ({
assistantAvailability: mockAssistantAvailability(),
showAssistantOverlay: mockShowAssistantOverlay,
}),
}));
describe('AssistantHeaderLink', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the header link text', () => {
const { queryByText, queryByTestId } = render(<AssistantHeaderLink />);
expect(queryByTestId('assistantHeaderLink')).toBeInTheDocument();
expect(queryByText('AI Assistant')).toBeInTheDocument();
});
it('should not render the header link if not authorized', () => {
mockAssistantAvailability.mockReturnValueOnce({ hasAssistantPrivilege: false });
const { queryByText, queryByTestId } = render(<AssistantHeaderLink />);
expect(queryByTestId('assistantHeaderLink')).not.toBeInTheDocument();
expect(queryByText('AI Assistant')).not.toBeInTheDocument();
});
it('should call the assistant overlay to show on click', () => {
const { queryByTestId } = render(<AssistantHeaderLink />);
queryByTestId('assistantHeaderLink')?.click();
expect(mockShowAssistantOverlay).toHaveBeenCalledTimes(1);
expect(mockShowAssistantOverlay).toHaveBeenCalledWith({ showOverlay: true });
});
});

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiToolTip } from '@elastic/eui';
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { useAssistantContext } from '@kbn/elastic-assistant/impl/assistant_context';
import { AssistantIcon } from '@kbn/ai-assistant-icon';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const TOOLTIP_CONTENT = i18n.translate(
'xpack.securitySolution.globalHeader.assistantHeaderLinkShortcutTooltip',
{
values: { keyboardShortcut: isMac ? '⌘ ;' : 'Ctrl ;' },
defaultMessage: 'Keyboard shortcut {keyboardShortcut}',
}
);
const LINK_LABEL = i18n.translate('xpack.securitySolution.globalHeader.assistantHeaderLink', {
defaultMessage: 'AI Assistant',
});
/**
* Elastic AI Assistant header link
*/
export const AssistantHeaderLink = () => {
const { showAssistantOverlay, assistantAvailability } = useAssistantContext();
const showOverlay = useCallback(
() => showAssistantOverlay({ showOverlay: true }),
[showAssistantOverlay]
);
if (!assistantAvailability.hasAssistantPrivilege) {
return null;
}
return (
<EuiToolTip content={TOOLTIP_CONTENT}>
<EuiHeaderLink data-test-subj="assistantHeaderLink" color="primary" onClick={showOverlay}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<AssistantIcon size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>{LINK_LABEL}</EuiFlexItem>
</EuiFlexGroup>
</EuiHeaderLink>
</EuiToolTip>
);
};

View file

@ -143,6 +143,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
triggersActionsUi: { actionTypeRegistry },
docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION },
userProfile,
chrome,
} = useKibana().services;
const basePath = useBasePath();
@ -227,6 +228,7 @@ export const AssistantProvider: FC<PropsWithChildren<unknown>> = ({ children })
toasts={toasts}
currentAppId={currentAppId ?? 'securitySolutionUI'}
userProfileService={userProfile}
chrome={chrome}
>
{children}
</ElasticAssistantProvider>

View file

@ -11,6 +11,8 @@ import React from 'react';
import type { AssistantAvailability } from '@kbn/elastic-assistant';
import { AssistantProvider } from '@kbn/elastic-assistant';
import type { UserProfileService } from '@kbn/core/public';
import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks';
import { of } from 'rxjs';
import { BASE_SECURITY_CONVERSATIONS } from '../../assistant/content/conversations';
interface Props {
@ -37,6 +39,8 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
hasManageGlobalKnowledgeBase: true,
isAssistantEnabled: true,
};
const chrome = chromeServiceMock.createStartContract();
chrome.getChromeStyle$.mockReturnValue(of('classic'));
const mockUserProfileService = {
getCurrent: jest.fn(() => Promise.resolve({ avatar: 'avatar' })),
@ -58,6 +62,7 @@ export const MockAssistantProviderComponent: React.FC<Props> = ({
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'test'}
userProfileService={mockUserProfileService}
chrome={chrome}
>
{children}
</AssistantProvider>

View file

@ -19,6 +19,8 @@ import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/a
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BASE_SECURITY_CONVERSATIONS } from '../../../../assistant/content/conversations';
import type { UserProfileService } from '@kbn/core-user-profile-browser';
import { chromeServiceMock } from '@kbn/core/public/mocks';
import { of } from 'rxjs';
jest.mock('../../../../common/lib/kibana');
@ -51,28 +53,33 @@ const queryClient = new QueryClient({
},
});
const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
assistantAvailability={mockAssistantAvailability}
augmentMessageCodeBlocks={jest.fn()}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
getComments={mockGetComments}
http={mockHttp}
navigateToApp={mockNavigationToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'security'}
userProfileService={jest.fn() as unknown as UserProfileService}
>
{children}
</AssistantProvider>
</QueryClientProvider>
);
const ContextWrapper: FC<PropsWithChildren<unknown>> = ({ children }) => {
const chrome = chromeServiceMock.createStartContract();
chrome.getChromeStyle$.mockReturnValue(of('classic'));
return (
<QueryClientProvider client={queryClient}>
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
assistantAvailability={mockAssistantAvailability}
augmentMessageCodeBlocks={jest.fn()}
basePath={'https://localhost:5601/kbn'}
docLinks={{
ELASTIC_WEBSITE_URL: 'https://www.elastic.co/',
DOC_LINK_VERSION: 'current',
}}
getComments={mockGetComments}
http={mockHttp}
navigateToApp={mockNavigationToApp}
baseConversations={BASE_SECURITY_CONVERSATIONS}
currentAppId={'security'}
userProfileService={jest.fn() as unknown as UserProfileService}
chrome={chrome}
>
{children}
</AssistantProvider>
</QueryClientProvider>
);
};
describe('RuleStatusFailedCallOut', () => {
const renderWith = (status: RuleExecutionStatus | null | undefined) =>

View file

@ -235,8 +235,9 @@
"@kbn/react-hooks",
"@kbn/index-adapter",
"@kbn/core-http-server-utils",
"@kbn/llm-tasks-plugin",
"@kbn/core-chrome-browser-mocks",
"@kbn/ai-assistant-icon",
"@kbn/llm-tasks-plugin",
"@kbn/charts-theme"
]
}

View file

@ -8,7 +8,7 @@
export const ADD_NEW_CONNECTOR = '[data-test-subj="addNewConnectorButton"]';
export const ADD_QUICK_PROMPT = '[data-test-subj="addQuickPrompt"]';
export const ASSISTANT_SETTINGS_BUTTON = 'button[data-test-subj="settings"]';
export const AI_ASSISTANT_BUTTON = '[data-test-subj="assistantHeaderLink"]';
export const AI_ASSISTANT_BUTTON = '[data-test-subj="assistantNavLink"]';
export const ASSISTANT_CHAT_BODY = '[data-test-subj="assistantChat"]';
export const CHAT_CONTEXT_MENU = '[data-test-subj="chat-context-menu"]';
export const CHAT_ICON = '[data-test-subj="newChat"]';