From 5a5d179d3fa6cca0a2dfb3fe6a1c1282666a85fa Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 12 Jul 2023 02:17:38 -0600 Subject: [PATCH] [8.9] [Security solution] Create AI assistant availability model (#161027) (#161692) # Backport This will backport the following commits from `main` to `8.9`: - [[Security solution] Create AI assistant availability model (#161027)](https://github.com/elastic/kibana/pull/161027) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) --- .../assistant/assistant_overlay/index.tsx | 12 +- .../impl/assistant/index.tsx | 209 ++++++++++++------ .../impl/assistant/use_conversation/index.tsx | 18 +- .../use_conversation/sample_conversations.tsx | 14 +- .../connectorland/connector_setup/index.tsx | 96 ++++---- .../content/prompts/welcome/translations.ts | 8 + .../settings/settings_popover/index.tsx | 5 +- .../impl/upgrade/translations.ts | 22 ++ .../impl/upgrade/upgrade_buttons.tsx | 39 ++++ .../common/experimental_features.ts | 15 -- .../alert_details_right_panel.cy.ts | 2 +- .../assistant/content/conversations/index.tsx | 35 +-- .../public/assistant/get_comments/index.tsx | 4 +- .../use_assistant_availability/index.tsx | 26 +++ .../common/components/page_wrapper/index.tsx | 6 +- .../mock/endpoint/app_root_provider.tsx | 9 +- .../common/mock/mock_assistant_provider.tsx | 48 ++++ .../common/mock/storybook_providers.tsx | 25 +-- .../public/common/mock/test_providers.tsx | 48 +--- .../pages/rule_management/super_header.tsx | 6 +- .../right/components/header_title.test.tsx | 25 +-- .../flyout/right/hooks/use_assistant.test.tsx | 14 +- .../flyout/right/hooks/use_assistant.ts | 8 +- .../public/overview/pages/data_quality.tsx | 6 +- .../event_details/expandable_event.tsx | 5 +- .../side_panel/event_details/index.tsx | 6 +- .../components/side_panel/index.test.tsx | 7 +- .../timeline/tabs_content/index.tsx | 62 ++++-- 28 files changed, 457 insertions(+), 323 deletions(-) create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/upgrade/translations.ts create mode 100644 x-pack/packages/kbn-elastic-assistant/impl/upgrade/upgrade_buttons.tsx create mode 100644 x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index 2f75406439e2b..285bc26240954 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -22,11 +22,15 @@ const StyledEuiModal = styled(EuiModal)` min-width: 95vw; min-height: 25vh; `; +interface Props { + isAssistantEnabled: boolean; +} + /** * Modal container for Elastic AI Assistant conversations, receiving the page contents as context, plus whatever * component currently has focus and any specific context it may provide through the SAssInterface. */ -export const AssistantOverlay: React.FC = React.memo(() => { +export const AssistantOverlay = React.memo(({ isAssistantEnabled }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [conversationId, setConversationId] = useState( WELCOME_CONVERSATION_TITLE @@ -79,7 +83,11 @@ export const AssistantOverlay: React.FC = React.memo(() => { <> {isModalVisible && ( - + )} diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx index 00fab15138f32..99f9744e9a949 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.tsx @@ -29,6 +29,7 @@ import { css } from '@emotion/react'; import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/gen_ai/constants'; import { ActionConnectorProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { UpgradeButtons } from '../upgrade/upgrade_buttons'; import { getMessageFromRawResponse } from './helpers'; import { ConversationSettingsPopover } from './conversation_settings_popover/conversation_settings_popover'; @@ -50,13 +51,14 @@ import { QuickPrompts } from './quick_prompts/quick_prompts'; import { useLoadConnectors } from '../connectorland/use_load_connectors'; import { useConnectorSetup } from '../connectorland/connector_setup'; import { WELCOME_CONVERSATION_TITLE } from './use_conversation/translations'; -import { BASE_CONVERSATIONS } from './use_conversation/sample_conversations'; +import { BASE_CONVERSATIONS, enterpriseMessaging } from './use_conversation/sample_conversations'; export interface Props { - promptContextId?: string; conversationId?: string; - showTitle?: boolean; + isAssistantEnabled: boolean; + promptContextId?: string; shouldRefocusPrompt?: boolean; + showTitle?: boolean; } /** @@ -64,10 +66,11 @@ export interface Props { * quick prompts for common actions, settings, and prompt context providers. */ const AssistantComponent: React.FC = ({ - promptContextId = '', - showTitle = true, conversationId = WELCOME_CONVERSATION_TITLE, + isAssistantEnabled, + promptContextId = '', shouldRefocusPrompt = false, + showTitle = true, }) => { const { actionTypeRegistry, @@ -101,10 +104,37 @@ const AssistantComponent: React.FC = ({ // Welcome conversation is a special 'setup' case when no connector exists, mostly extracted to `ConnectorSetup` component, // but currently a bit of state is littered throughout the assistant component. TODO: clean up/isolate this state - const welcomeConversation = useMemo( - () => conversations[selectedConversationId] ?? BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE], - [conversations, selectedConversationId] - ); + const welcomeConversation = useMemo(() => { + const conversation = + conversations[selectedConversationId] ?? BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE]; + const doesConversationHaveMessages = conversation.messages.length > 0; + if (!isAssistantEnabled) { + if ( + !doesConversationHaveMessages || + conversation.messages[conversation.messages.length - 1].content !== + enterpriseMessaging[0].content + ) { + return { + ...conversation, + messages: [...conversation.messages, ...enterpriseMessaging], + }; + } + return conversation; + } + + return doesConversationHaveMessages + ? { + ...conversation, + messages: [ + ...conversation.messages, + ...BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages, + ], + } + : { + ...conversation, + messages: BASE_CONVERSATIONS[WELCOME_CONVERSATION_TITLE].messages, + }; + }, [conversations, isAssistantEnabled, selectedConversationId]); const { data: connectors, refetch: refetchConnectors } = useLoadConnectors({ http }); const defaultConnectorId = useMemo(() => connectors?.[0]?.id, [connectors]); @@ -116,13 +146,19 @@ const AssistantComponent: React.FC = ({ ); const isWelcomeSetup = (connectors?.length ?? 0) === 0; + const isDisabled = isWelcomeSetup || !isAssistantEnabled; - const { connectorDialog, connectorPrompt } = useConnectorSetup({ + const { comments: connectorComments, prompt: connectorPrompt } = useConnectorSetup({ actionTypeRegistry, http, refetchConnectors, + onSetupComplete: () => { + bottomRef.current?.scrollIntoView({ behavior: 'auto' }); + }, + conversation: welcomeConversation, isConnectorConfigured: !!connectors?.length, }); + const currentTitle: { title: string | JSX.Element; titleIcon: string } = isWelcomeSetup && welcomeConversation.theme?.title && welcomeConversation.theme?.titleIcon ? { title: welcomeConversation.theme?.title, titleIcon: welcomeConversation.theme?.titleIcon } @@ -146,8 +182,8 @@ const AssistantComponent: React.FC = ({ }, [augmentMessageCodeBlocks, currentConversation]); const isSendingDisabled = useMemo(() => { - return isWelcomeSetup || showMissingConnectorCallout; - }, [showMissingConnectorCallout, isWelcomeSetup]); + return isDisabled || showMissingConnectorCallout; + }, [showMissingConnectorCallout, isDisabled]); // Fixes initial render not showing buttons as code block controls are added to the DOM really late useEffect(() => { @@ -270,16 +306,6 @@ const AssistantComponent: React.FC = ({ [setShowAnonymizedValues] ); - const comments = useMemo( - () => - getComments({ - currentConversation, - lastCommentRef, - showAnonymizedValues, - }), - [currentConversation, getComments, showAnonymizedValues] - ); - useEffect(() => { // Adding `conversationId !== selectedConversationId` to prevent auto-run still executing after changing selected conversation if (currentConversation.messages.length || conversationId !== selectedConversationId) { @@ -348,6 +374,66 @@ const AssistantComponent: React.FC = ({ }), [messageCodeBlocks] ); + + const chatbotComments = useMemo( + () => ( + <> + + + + + {(currentConversation.messages.length === 0 || + Object.keys(selectedPromptContexts).length > 0) && ( + + )} + +
+ + ), + [ + currentConversation, + getComments, + promptContexts, + promptTextPreview, + selectedPromptContexts, + showAnonymizedValues, + ] + ); + + const comments = useMemo(() => { + if (isDisabled) { + return ( + <> + + + + ); + } + + return chatbotComments; + }, [connectorComments, isDisabled, chatbotComments]); + return ( <> = ({ defaultProvider={defaultProvider} onSelectionChange={(id) => setSelectedConversationId(id)} shouldDisableKeyboardShortcut={shouldDisableConversationSelectorHotkeys} - isDisabled={isWelcomeSetup} + isDisabled={isDisabled} /> <> @@ -415,7 +501,7 @@ const AssistantComponent: React.FC = ({ - + @@ -441,7 +527,7 @@ const AssistantComponent: React.FC = ({ {/* Create portals for each EuiCodeBlock to add the `Investigate in Timeline` action */} {createCodeBlockPortals()} - {!isWelcomeSetup && ( + {!isDisabled && ( <> = ({ )} - - {isWelcomeSetup ? ( - connectorDialog - ) : ( - <> - - - - - {(currentConversation.messages.length === 0 || - Object.keys(selectedPromptContexts).length > 0) && ( - - )} - -
- - )} - - - + {comments} - - {isWelcomeSetup && {connectorPrompt}} - + {!isAssistantEnabled ? ( + + + {} + + + ) : ( + isWelcomeSetup && ( + + {connectorPrompt} + + ) + )} = ({ onPromptSubmit={handleSendMessage} ref={promptTextAreaRef} handlePromptChange={setPromptTextPreview} - value={isWelcomeSetup ? '' : suggestedUserPrompt ?? ''} - isDisabled={isWelcomeSetup} + value={isDisabled ? '' : suggestedUserPrompt ?? ''} + isDisabled={isDisabled} /> @@ -536,7 +605,7 @@ const AssistantComponent: React.FC = ({ { @@ -565,7 +634,7 @@ const AssistantComponent: React.FC = ({ @@ -573,7 +642,7 @@ const AssistantComponent: React.FC = ({ - {!isWelcomeSetup && } + {!isDisabled && } ); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx index 08124d750d949..217613b239dee 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/index.tsx @@ -61,10 +61,7 @@ interface UseConversation { replacements, }: AppendReplacementsProps) => Record; clearConversation: (conversationId: string) => void; - createConversation: ({ - conversationId, - messages, - }: CreateConversationProps) => Conversation | undefined; + createConversation: ({ conversationId, messages }: CreateConversationProps) => Conversation; deleteConversation: (conversationId: string) => void; setApiConfig: ({ conversationId, apiConfig }: SetApiConfigProps) => void; setConversation: ({ conversation }: SetConversationProps) => void; @@ -162,18 +159,17 @@ export const useConversation = (): UseConversation => { * Create a new conversation with the given conversationId, and optionally add messages */ const createConversation = useCallback( - ({ conversationId, messages }: CreateConversationProps): Conversation | undefined => { - let newConversation: Conversation | undefined; + ({ conversationId, messages }: CreateConversationProps): Conversation => { + const newConversation: Conversation = { + ...DEFAULT_CONVERSATION_STATE, + id: conversationId, + messages: messages != null ? messages : [], + }; setConversations((prev: Record) => { const prevConversation: Conversation | undefined = prev[conversationId]; if (prevConversation != null) { throw new Error('Conversation already exists!'); } else { - newConversation = { - ...DEFAULT_CONVERSATION_STATE, - id: conversationId, - messages: messages != null ? messages : [], - }; return { ...prev, [conversationId]: { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx index a49cd0e22a0f4..60bb89c4e355f 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/use_conversation/sample_conversations.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { Conversation } from '../../assistant_context/types'; +import { Conversation, Message } from '../../assistant_context/types'; import * as i18n from '../../content/prompts/welcome/translations'; import { DEFAULT_CONVERSATION_TITLE, @@ -135,3 +135,15 @@ export const BASE_CONVERSATIONS: Record = { apiConfig: {}, }, }; + +export const enterpriseMessaging: Message[] = [ + { + role: 'assistant', + content: i18n.ENTERPRISE, + timestamp: '', + presentation: { + delay: 2 * 1000, + stream: true, + }, + }, +]; diff --git a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx index 66c58e2b9ac61..a1d8398437398 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/connectorland/connector_setup/index.tsx @@ -7,14 +7,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { EuiCommentProps } from '@elastic/eui'; -import { - EuiAvatar, - EuiBadge, - EuiCommentList, - EuiMarkdownFormat, - EuiText, - EuiTextAlign, -} from '@elastic/eui'; +import { EuiAvatar, EuiBadge, EuiMarkdownFormat, EuiText, EuiTextAlign } from '@elastic/eui'; // eslint-disable-next-line @kbn/eslint/module_migration import styled from 'styled-components'; import { ConnectorAddModal } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; @@ -39,10 +32,6 @@ import { WELCOME_CONVERSATION_TITLE } from '../../assistant/use_conversation/tra const MESSAGE_INDEX_BEFORE_CONNECTOR = 2; -const StyledCommentList = styled(EuiCommentList)` - margin-right: 20px; -`; - const ConnectorButtonWrapper = styled.div` margin-top: 20px; `; @@ -72,12 +61,11 @@ export const useConnectorSetup = ({ onSetupComplete, refetchConnectors, }: ConnectorSetupProps): { - connectorDialog: React.ReactElement; - connectorPrompt: React.ReactElement; + comments: EuiCommentProps[]; + prompt: React.ReactElement; } => { const { appendMessage, setApiConfig, setConversation } = useConversation(); - const lastCommentRef = useRef(null); - + const bottomRef = useRef(null); // Access all conversations so we can add connector to all on initial setup const { conversations } = useAssistantContext(); @@ -114,17 +102,17 @@ export const useConnectorSetup = ({ // Once streaming of previous message is complete, proceed to next message const onHandleMessageStreamingComplete = useCallback(() => { - const timeoutId = setTimeout( - () => setCurrentMessageIndex(currentMessageIndex + 1), - conversation.messages[currentMessageIndex].presentation?.delay ?? 0 - ); - + const timeoutId = setTimeout(() => { + bottomRef.current?.scrollIntoView({ block: 'end' }); + return setCurrentMessageIndex(currentMessageIndex + 1); + }, conversation.messages[currentMessageIndex]?.presentation?.delay ?? 0); return () => clearTimeout(timeoutId); }, [conversation.messages, currentMessageIndex]); // Show button to add connector after last message has finished streaming const onHandleLastMessageStreamingComplete = useCallback(() => { setShowAddConnectorButton(true); + bottomRef.current?.scrollIntoView({ block: 'end' }); onSetupComplete?.(); setConversation({ conversation: clearPresentationData(conversation) }); }, [conversation, onSetupComplete, setConversation]); @@ -138,12 +126,15 @@ export const useConnectorSetup = ({ const commentBody = useCallback( (message: Message, index: number, length: number) => { // If timestamp is not set, set it to current time (will update conversation at end of setup) - if (conversation.messages[index].timestamp.length === 0) { + if ( + conversation.messages[index].timestamp == null || + conversation.messages[index].timestamp.length === 0 + ) { conversation.messages[index].timestamp = new Date().toLocaleString(); } const isLastMessage = index === length - 1; const enableStreaming = - (message.presentation?.stream ?? false) && currentMessageIndex !== length - 1; + (message?.presentation?.stream ?? false) && currentMessageIndex !== length - 1; return ( ( {streamedText} - {isLastMessage && isStreamingComplete && } + )} @@ -169,31 +160,40 @@ export const useConnectorSetup = ({ ] ); + const comments = useMemo( + () => + conversation.messages.slice(0, currentMessageIndex + 1).map((message, index) => { + const isUser = message.role === 'user'; + + const commentProps: EuiCommentProps = { + username: isUser ? userName : assistantName, + children: commentBody(message, index, conversation.messages.length), + timelineAvatar: ( + + ), + timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`, + }; + return commentProps; + }), + [ + assistantName, + commentBody, + conversation.messages, + conversation?.theme?.assistant?.icon, + currentMessageIndex, + userName, + ] + ); + return { - connectorDialog: ( - { - const isUser = message.role === 'user'; - - const commentProps: EuiCommentProps = { - username: isUser ? userName : assistantName, - children: commentBody(message, index, conversation.messages.length), - timelineAvatar: ( - - ), - timestamp: `${i18n.CONNECTOR_SETUP_TIMESTAMP_AT}: ${message.timestamp}`, - }; - return commentProps; - })} - /> - ), - connectorPrompt: ( -
+ comments, + prompt: ( +
{(showAddConnectorButton || isConnectorConfigured) && ( { +const SettingsPopoverComponent: React.FC<{ isDisabled?: boolean }> = ({ isDisabled = false }) => { const [showAnonymizationSettingsModal, setShowAnonymizationSettingsModal] = useState(false); const closeAnonymizationSettingsModal = useCallback( () => setShowAnonymizationSettingsModal(false), @@ -33,13 +33,14 @@ const SettingsPopoverComponent: React.FC = () => { const button = useMemo( () => ( ), - [onButtonClick] + [isDisabled, onButtonClick] ); const panels: EuiContextMenuPanelDescriptor[] = useMemo( diff --git a/x-pack/packages/kbn-elastic-assistant/impl/upgrade/translations.ts b/x-pack/packages/kbn-elastic-assistant/impl/upgrade/translations.ts new file mode 100644 index 0000000000000..9fe3df3d25be6 --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/upgrade/translations.ts @@ -0,0 +1,22 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const UPGRADE_CTA = i18n.translate( + 'xpack.elasticAssistant.components.upgrade.upgradeTitle', + { + defaultMessage: 'Manage license', + } +); + +export const UPGRADE_DOCS = i18n.translate( + 'xpack.elasticAssistant.components.upgrade.upgradeButtonLabel', + { + defaultMessage: 'Subscription plans', + } +); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/upgrade/upgrade_buttons.tsx b/x-pack/packages/kbn-elastic-assistant/impl/upgrade/upgrade_buttons.tsx new file mode 100644 index 0000000000000..06d1137ba0ecb --- /dev/null +++ b/x-pack/packages/kbn-elastic-assistant/impl/upgrade/upgrade_buttons.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +export const UpgradeButtonsComponent = ({ basePath }: { basePath: string }) => ( + + + + {i18n.UPGRADE_DOCS} + + + + + {i18n.UPGRADE_CTA} + + + +); + +export const UpgradeButtons = React.memo(UpgradeButtonsComponent); + +UpgradeButtons.displayName = 'UpgradeButtons'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index dd00c9b3ab190..f12e31724f8f1 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -81,21 +81,6 @@ export const allowedExperimentalValues = Object.freeze({ */ securityFlyoutEnabled: false, - /** - * Enables the Elastic AI Assistant - */ - assistantEnabled: false, - - /** - * Keep DEPRECATED experimental flags that are documented to prevent failed upgrades. - * https://www.elastic.co/guide/en/security/current/user-risk-score.html - * https://www.elastic.co/guide/en/security/current/host-risk-score.html - * - * Issue: https://github.com/elastic/kibana/issues/146777 - */ - riskyHostsEnabled: false, // DEPRECATED - riskyUsersEnabled: false, // DEPRECATED - /* * Enables new Set of filters on the Alerts page. * diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel.cy.ts index 1f28849104399..fb21d9d5a445d 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/alerts/expandable_flyout/alert_details_right_panel.cy.ts @@ -66,7 +66,7 @@ import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule'; describe( 'Alert details expandable flyout right panel', - { env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled', 'assistantEnabled'] } } }, + { env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled'] } } }, () => { const rule = getNewRule(); diff --git a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx index b499092240377..b2e4993d9763d 100644 --- a/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/content/conversations/index.tsx @@ -10,11 +10,6 @@ import { WELCOME_CONVERSATION_TITLE, } from '@kbn/elastic-assistant/impl/assistant/use_conversation/translations'; import type { Conversation } from '@kbn/elastic-assistant'; -import { - WELCOME_GENERAL, - WELCOME_GENERAL_2, - WELCOME_GENERAL_3, -} from '@kbn/elastic-assistant/impl/content/prompts/welcome/translations'; import { DATA_QUALITY_DASHBOARD_CONVERSATION_ID } from '@kbn/ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/translations'; import { DETECTION_RULES_CONVERSATION_ID } from '../../../detections/pages/detection_engine/rules/translations'; import { @@ -70,35 +65,7 @@ export const BASE_SECURITY_CONVERSATIONS: Record = { }, user: {}, }, - messages: [ - { - role: 'assistant', - content: WELCOME_GENERAL, - timestamp: '', - presentation: { - delay: 2 * 1000, - stream: true, - }, - }, - { - role: 'assistant', - content: WELCOME_GENERAL_2, - timestamp: '', - presentation: { - delay: 1000, - stream: true, - }, - }, - { - role: 'assistant', - content: WELCOME_GENERAL_3, - timestamp: '', - presentation: { - delay: 1000, - stream: true, - }, - }, - ], + messages: [], apiConfig: {}, }, }; diff --git a/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx index d2c051285c8cd..af644f8a4c5e5 100644 --- a/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/assistant/get_comments/index.tsx @@ -59,7 +59,9 @@ export const getComments = ({ ) : ( ), - timestamp: i18n.AT(message.timestamp), + timestamp: i18n.AT( + message.timestamp.length === 0 ? new Date().toLocaleString() : message.timestamp + ), username: isUser ? i18n.YOU : i18n.ASSISTANT, }; }); diff --git a/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx b/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx new file mode 100644 index 0000000000000..fb58fb5509ba2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/assistant/use_assistant_availability/index.tsx @@ -0,0 +1,26 @@ +/* + * 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 { useLicense } from '../../common/hooks/use_license'; + +export interface UseAssistantAvailability { + // True when user is Enterprise. When false, the Assistant is disabled and unavailable + isAssistantEnabled: boolean; + // When true, the Assistant is hidden and unavailable + hasAssistantPrivilege: boolean; +} + +export const useAssistantAvailability = (): UseAssistantAvailability => { + const isEnterprise = useLicense().isEnterprise(); + return { + isAssistantEnabled: isEnterprise, + // TODO: RBAC check (https://github.com/elastic/security-team/issues/6932) + // Leaving as a placeholder for RBAC as the same behavior will be required + // When false, the Assistant is hidden and unavailable + hasAssistantPrivilege: true, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx index 6cf4167ea6d6e..f554ec01657c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page_wrapper/index.tsx @@ -11,8 +11,8 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; import type { CommonProps } from '@elastic/eui'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; import { useGlobalFullScreen } from '../../containers/use_full_screen'; -import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { AppGlobalStyle } from '../page'; const Wrapper = styled.div` @@ -43,7 +43,7 @@ interface SecuritySolutionPageWrapperProps { const SecuritySolutionPageWrapperComponent: React.FC< SecuritySolutionPageWrapperProps & CommonProps > = ({ children, className, style, noPadding, noTimeline, ...otherProps }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { isAssistantEnabled, hasAssistantPrivilege } = useAssistantAvailability(); const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); useEffect(() => { setGlobalFullScreen(false); // exit full screen mode on page load @@ -60,7 +60,7 @@ const SecuritySolutionPageWrapperComponent: React.FC< {children} - {isAssistantEnabled && } + {hasAssistantPrivilege && } ); }; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_root_provider.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_root_provider.tsx index f2127e89429b3..19c9a0271b7a2 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_root_provider.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_root_provider.tsx @@ -16,6 +16,7 @@ import type { Store } from 'redux'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { CoreStart } from '@kbn/core/public'; +import { MockAssistantProvider } from '../mock_assistant_provider'; import { RouteCapture } from '../../components/endpoint/route_capture'; import type { StartPlugins } from '../../../types'; @@ -46,9 +47,11 @@ export const AppRootProvider = memo<{ - - {children} - + + + {children} + + diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx new file mode 100644 index 0000000000000..83e34c9bb215f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -0,0 +1,48 @@ +/* + * 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 { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; +import React from 'react'; +import { AssistantProvider } from '@kbn/elastic-assistant'; + +interface Props { + children: React.ReactNode; +} + +window.scrollTo = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +/** A utility for wrapping children in the providers required to run tests */ +export const MockAssistantProviderComponent: React.FC = ({ children }) => { + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); + mockHttp.get.mockResolvedValue([]); + + return ( + [])} + baseAllow={[]} + baseAllowReplacement={[]} + defaultAllow={[]} + defaultAllowReplacement={[]} + getComments={jest.fn(() => [])} + getInitialConversations={jest.fn(() => ({}))} + setConversations={jest.fn()} + setDefaultAllow={jest.fn()} + setDefaultAllowReplacement={jest.fn()} + http={mockHttp} + > + {children} + + ); +}; + +MockAssistantProviderComponent.displayName = 'MockAssistantProviderComponent'; + +export const MockAssistantProvider = React.memo(MockAssistantProviderComponent); diff --git a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx index d80a14872ccf6..99605e7a67693 100644 --- a/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/storybook_providers.tsx @@ -5,18 +5,16 @@ * 2.0. */ -import { AssistantProvider } from '@kbn/elastic-assistant'; import { euiLightVars } from '@kbn/ui-theme'; import React from 'react'; import { Provider as ReduxStoreProvider } from 'react-redux'; import { BehaviorSubject, Subject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; -import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import type { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { CellActionsProvider } from '@kbn/cell-actions'; -import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; +import { MockAssistantProvider } from './mock_assistant_provider'; import { createStore } from '../store'; import { mockGlobalState } from './global_state'; import { SUB_PLUGINS_REDUCER } from './utils'; @@ -55,10 +53,6 @@ const KibanaReactContext = createKibanaReactContext(coreMock); */ export const StorybookProviders: React.FC = ({ children }) => { const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - const actionTypeRegistry = actionTypeRegistryMock.create(); - const mockGetInitialConversations = jest.fn(() => ({})); - const mockGetComments = jest.fn(() => []); - const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); return ( @@ -66,22 +60,7 @@ export const StorybookProviders: React.FC = ({ children }) => { Promise.resolve([])}> ({ eui: euiLightVars, darkMode: false })}> - - {children} - + {children} diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 61f0a69266af5..9672f2563dc5a 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import { AssistantProvider } from '@kbn/elastic-assistant'; import { euiDarkVars } from '@kbn/ui-theme'; import { I18nProvider } from '@kbn/i18n-react'; @@ -17,13 +16,12 @@ import type { Store } from 'redux'; import { BehaviorSubject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import type { Capabilities } from '@kbn/core/public'; -import { httpServiceMock } from '@kbn/core-http-browser-mocks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { Action } from '@kbn/ui-actions-plugin/public'; import { CellActionsProvider } from '@kbn/cell-actions'; import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout'; -import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; +import { MockAssistantProvider } from './mock_assistant_provider'; import { ConsoleManager } from '../../management/components/console'; import type { State } from '../store'; import { createStore } from '../store'; @@ -64,30 +62,12 @@ export const TestProvidersComponent: React.FC = ({ cellActions = [], }) => { const queryClient = new QueryClient(); - const actionTypeRegistry = actionTypeRegistryMock.create(); - const mockGetInitialConversations = jest.fn(() => ({})); - const mockGetComments = jest.fn(() => []); - const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); - return ( ({ eui: euiDarkVars, darkMode: true })}> - + @@ -99,7 +79,7 @@ export const TestProvidersComponent: React.FC = ({ - + @@ -117,30 +97,12 @@ const TestProvidersWithPrivilegesComponent: React.FC = ({ onDragEnd = jest.fn(), cellActions = [], }) => { - const actionTypeRegistry = actionTypeRegistryMock.create(); - const mockGetInitialConversations = jest.fn(() => ({})); - const mockGetComments = jest.fn(() => []); - const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); - return ( ({ eui: euiDarkVars, darkMode: true })}> - + = ({ {children} - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx index e7bdaca00f2d1..b3e7d6f3f6401 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/super_header.tsx @@ -8,14 +8,14 @@ import { NewChat } from '@kbn/elastic-assistant'; import React, { useCallback, useMemo } from 'react'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import { getPromptContextFromDetectionRules } from '../../../../assistant/helpers'; import { HeaderPage } from '../../../../common/components/header_page'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useRulesTableContext } from '../../components/rules_table/rules_table/rules_table_context'; import * as i18n from '../../../../detections/pages/detection_engine/rules/translations'; export const SuperHeader: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege } = useAssistantAvailability(); const memoizedChildren = useMemo(() => children, [children]); // Rules state const { @@ -37,7 +37,7 @@ export const SuperHeader: React.FC<{ children: React.ReactNode }> = React.memo(( title={ <> {i18n.PAGE_TITLE}{' '} - {isAssistantEnabled && selectedRules.length > 0 && ( + {hasAssistantPrivilege && selectedRules.length > 0 && ( ({})); -const mockGetComments = jest.fn(() => []); -const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' }); const renderHeader = (contextValue: RightPanelContext) => render( - + - + ); describe('', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.test.tsx index b11fbbb871e32..1a538a2ceaeae 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.test.tsx @@ -10,10 +10,10 @@ import { renderHook } from '@testing-library/react-hooks'; import type { UseAssistantParams, UseAssistantResult } from './use_assistant'; import { useAssistant } from './use_assistant'; import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { useAssistantOverlay } from '@kbn/elastic-assistant'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; -jest.mock('../../../common/hooks/use_experimental_features'); +jest.mock('../../../assistant/use_assistant_availability'); jest.mock('@kbn/elastic-assistant'); const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser; @@ -28,7 +28,9 @@ describe('useAssistant', () => { let hookResult: RenderHookResult; it(`should return showAssistant true and a value for promptContextId`, () => { - jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true); + jest + .mocked(useAssistantAvailability) + .mockReturnValue({ hasAssistantPrivilege: true, isAssistantEnabled: true }); jest .mocked(useAssistantOverlay) .mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' }); @@ -39,8 +41,10 @@ describe('useAssistant', () => { expect(hookResult.result.current.promptContextId).toEqual('123'); }); - it(`should return showAssistant false if feature flag is off`, () => { - jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false); + it(`should return showAssistant false if hasAssistantPrivilege is false`, () => { + jest + .mocked(useAssistantAvailability) + .mockReturnValue({ hasAssistantPrivilege: false, isAssistantEnabled: true }); jest .mocked(useAssistantOverlay) .mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' }); diff --git a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.ts b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.ts index 2260721a97d68..13392692e6746 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/hooks/use_assistant.ts @@ -8,7 +8,7 @@ import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { useAssistantOverlay } from '@kbn/elastic-assistant'; import { useCallback } from 'react'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; import { getPromptContextFromEventDetailsItem } from '../../../assistant/helpers'; import { ALERT_SUMMARY_CONTEXT_DESCRIPTION, @@ -56,8 +56,8 @@ export const useAssistant = ({ dataFormattedForFieldBrowser, isAlert, }: UseAssistantParams): UseAssistantResult => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); - const useAssistantHook = isAssistantEnabled ? useAssistantOverlay : useAssistantNoop; + const { hasAssistantPrivilege } = useAssistantAvailability(); + const useAssistantHook = hasAssistantPrivilege ? useAssistantOverlay : useAssistantNoop; const getPromptContext = useCallback( async () => getPromptContextFromEventDetailsItem(dataFormattedForFieldBrowser ?? []), [dataFormattedForFieldBrowser] @@ -77,7 +77,7 @@ export const useAssistant = ({ ); return { - showAssistant: isAssistantEnabled && promptContextId !== null, + showAssistant: hasAssistantPrivilege && promptContextId !== null, promptContextId: promptContextId || '', }; }; diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index 5a5667bbbd2f2..cd0b0a68c1ad1 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -29,6 +29,7 @@ import { import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { useAssistantAvailability } from '../../assistant/use_assistant_availability'; import { SecurityPageName } from '../../app/types'; import { getGroupByFieldsOnClick } from '../../common/components/alerts_treemap/lib/helpers'; import { useThemes } from '../../common/components/charts/common'; @@ -38,7 +39,6 @@ import { useLocalStorage } from '../../common/components/local_storage'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { DEFAULT_BYTES_FORMAT, DEFAULT_NUMBER_FORMAT } from '../../../common/constants'; import { useSourcererDataView } from '../../common/containers/sourcerer'; -import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { KibanaServices, useGetUserCasesPermissions, @@ -129,7 +129,7 @@ const renderOption = ( ); const DataQualityComponent: React.FC = () => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege } = useAssistantAvailability(); const httpFetch = KibanaServices.get().http.fetch; const { baseTheme, theme } = useThemes(); const toasts = useToasts(); @@ -237,7 +237,7 @@ const DataQualityComponent: React.FC = () => { getGroupByFieldsOnClick={getGroupByFieldsOnClick} httpFetch={httpFetch} ilmPhases={ilmPhases} - isAssistantEnabled={isAssistantEnabled} + isAssistantEnabled={hasAssistantPrivilege} lastChecked={lastChecked} openCreateCaseFlyout={openCreateCaseFlyout} patterns={alertsAndSelectedPatterns} diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 5f4e7a087b02e..bb33bcd4e1be5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -22,6 +22,7 @@ import { import React from 'react'; import styled from 'styled-components'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { getAlertDetailsUrl } from '../../../../common/components/link_to'; import { @@ -96,7 +97,7 @@ export const ExpandableEventTitle = React.memo( ruleName, timestamp, }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege } = useAssistantAvailability(); const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); const { onClick } = useGetSecuritySolutionLinkProps()({ deepLinkId: SecurityPageName.alerts, @@ -152,7 +153,7 @@ export const ExpandableEventTitle = React.memo( )} - {isAssistantEnabled && promptContextId != null && ( + {hasAssistantPrivilege && promptContextId != null && ( = ({ scopeId, isReadOnly, }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege } = useAssistantAvailability(); // TODO: changing feature flags requires a hard refresh to take effect, but this temporary workaround technically violates the rules of hooks: - const useAssistant = isAssistantEnabled ? useAssistantOverlay : useAssistantNoop; + const useAssistant = hasAssistantPrivilege ? useAssistantOverlay : useAssistantNoop; const currentSpaceId = useSpaceId(); const { indexName } = expandedEvent; const eventIndex = getAlertIndexAlias(indexName, currentSpaceId) ?? indexName; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx index fd2da0c570f5f..25ceb5836b908 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/index.test.tsx @@ -24,11 +24,13 @@ import { FlowTargetSourceDest } from '../../../../common/search_strategy/securit import { EventDetailsPanel } from './event_details'; import { useSearchStrategy } from '../../../common/containers/use_search_strategy'; import type { ExpandedDetailTimeline } from '../../../../common/types'; +import { useAssistantAvailability } from '../../../assistant/use_assistant_availability'; jest.mock('../../../common/containers/use_search_strategy', () => ({ useSearchStrategy: jest.fn(), })); +jest.mock('../../../assistant/use_assistant_availability'); const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' }); jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -37,7 +39,6 @@ jest.mock('react-router-dom', () => { useLocation: () => mockUseLocation(), }; }); - describe('Details Panel Component', () => { const state: State = { ...mockGlobalState, @@ -112,6 +113,10 @@ describe('Details Panel Component', () => { describe('DetailsPanel: rendering', () => { beforeEach(() => { + (useAssistantAvailability as jest.Mock).mockReturnValue({ + hasAssistantPrivilege: false, + isAssistantEnabled: true, + }); store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index 0084a9a5358da..bb6a5a22fc6c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -13,6 +13,8 @@ import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 're import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { useConversationStore } from '../../../../assistant/use_conversation_store'; +import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability'; import type { SessionViewConfig } from '../../../../../common/types'; import type { RowRenderer, TimelineId } from '../../../../../common/types/timeline'; import { TimelineTabs } from '../../../../../common/types/timeline'; @@ -36,7 +38,6 @@ import { getEventIdToNoteIdsSelector, } from './selectors'; import * as i18n from './translations'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useLicense } from '../../../../common/hooks/use_license'; import { TIMELINE_CONVERSATION_TITLE } from '../../../../assistant/content/conversations/translations'; @@ -141,20 +142,24 @@ const PinnedTab: React.FC<{ PinnedTab.displayName = 'PinnedTab'; const AssistantTab: React.FC<{ + isAssistantEnabled: boolean; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; timelineId: TimelineId; shouldRefocusPrompt: boolean; -}> = memo(({ renderCellValue, rowRenderers, timelineId, shouldRefocusPrompt }) => ( - }> - - - - -)); +}> = memo( + ({ isAssistantEnabled, renderCellValue, rowRenderers, timelineId, shouldRefocusPrompt }) => ( + }> + + + + + ) +); AssistantTab.displayName = 'AssistantTab'; @@ -172,7 +177,7 @@ const ActiveTimelineTab = memo( timelineType, showTimeline, }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege, isAssistantEnabled } = useAssistantAvailability(); const getTab = useCallback( (tab: TimelineTabs) => { switch (tab) { @@ -195,6 +200,13 @@ const ActiveTimelineTab = memo( [activeTimelineTab] ); + const { conversations } = useConversationStore(); + + const hasTimelineConversationStarted = useMemo( + () => conversations[TIMELINE_CONVERSATION_TITLE].messages.length > 0, + [conversations] + ); + /* Future developer -> why are we doing that * It is really expansive to re-render the QueryTab because the drag/drop * Therefore, we are only hiding its dom when switching to another tab @@ -241,7 +253,7 @@ const ActiveTimelineTab = memo( > {isGraphOrNotesTabs && getTab(activeTimelineTab)} - {isAssistantEnabled && ( + {hasAssistantPrivilege && ( ( overflow: hidden !important; `} > - + {(activeTimelineTab === TimelineTabs.securityAssistant || + hasTimelineConversationStarted) && ( + + )} )} @@ -297,7 +313,7 @@ const TabsContentComponent: React.FC = ({ sessionViewConfig, timelineDescription, }) => { - const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled'); + const { hasAssistantPrivilege } = useAssistantAvailability(); const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); const getShowTimeline = useMemo(() => getShowTimelineSelector(), []); @@ -452,7 +468,7 @@ const TabsContentComponent: React.FC = ({
)} - {isAssistantEnabled && ( + {hasAssistantPrivilege && (