diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx index 5f8f9fa4c3809..e4a519ac24159 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/assistant_overlay/index.tsx @@ -12,12 +12,13 @@ import useEvent from 'react-use/lib/useEvent'; import { css } from '@emotion/react'; import { createGlobalStyle } from 'styled-components'; -import { - LastConversation, - ShowAssistantOverlayProps, - useAssistantContext, -} from '../../assistant_context'; +import { ShowAssistantOverlayProps, useAssistantContext } from '../../assistant_context'; import { Assistant, CONVERSATION_SIDE_PANEL_WIDTH } from '..'; +import { + useAssistantLastConversation, + useAssistantSpaceId, + type LastConversation, +} from '../use_space_aware_context'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; @@ -33,14 +34,15 @@ export const UnifiedTimelineGlobalStyles = createGlobalStyle` `; export const AssistantOverlay = React.memo(() => { + const spaceId = useAssistantSpaceId(); const [isModalVisible, setIsModalVisible] = useState(false); // id if the conversation exists in the data stream, title if it's a new conversation const [lastConversation, setSelectedConversation] = useState( undefined ); const [promptContextId, setPromptContextId] = useState(); - const { assistantTelemetry, setShowAssistantOverlay, getLastConversation } = - useAssistantContext(); + const { assistantTelemetry, setShowAssistantOverlay } = useAssistantContext(); + const { getLastConversation } = useAssistantLastConversation({ spaceId }); const [chatHistoryVisible, setChatHistoryVisible] = useState(false); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx index b97859daa7dd7..c4f575d5a3cfb 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/chat_send/use_chat_send.tsx @@ -18,6 +18,7 @@ import { useConversation } from '../use_conversation'; import { getCombinedMessage } from '../prompt/helpers'; import { Conversation, useAssistantContext } from '../../..'; import { getMessageFromRawResponse } from '../helpers'; +import { useAssistantSpaceId, useAssistantLastConversation } from '../use_space_aware_context'; export interface UseChatSendProps { currentConversation?: Conversation; @@ -55,8 +56,9 @@ export const useChatSend = ({ assistantTelemetry, toasts, assistantAvailability: { isAssistantEnabled }, - setLastConversation, } = useAssistantContext(); + const spaceId = useAssistantSpaceId(); + const { setLastConversation } = useAssistantLastConversation({ spaceId }); const [userPrompt, setUserPrompt] = useState(null); const { isLoading, sendMessage, abortStream } = useSendMessage(); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.test.tsx index ade0e3e237281..f0bfec48e4e6d 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -149,7 +149,7 @@ describe('Assistant', () => { .mockReturnValue(defaultFetchUserConversations as unknown as FetchCurrentUserConversations); jest .mocked(useLocalStorage) - .mockReturnValue([undefined, persistToLocalStorage] as unknown as ReturnType< + .mockReturnValue([mockData.welcome_id, persistToLocalStorage] as unknown as ReturnType< typeof useLocalStorage >); jest diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx index 84a5204863084..ccf43022fc754 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/index.tsx @@ -37,7 +37,7 @@ import { useChatSend } from './chat_send/use_chat_send'; import { ChatSend } from './chat_send'; import { getDefaultConnector } from './helpers'; -import { LastConversation, useAssistantContext } from '../assistant_context'; +import { useAssistantContext } from '../assistant_context'; import { ContextPills } from './context_pills'; import { getNewSelectedPromptContext } from '../data_anonymization/get_new_selected_prompt_context'; import type { PromptContext, SelectedPromptContext } from './prompt_context/types'; @@ -53,6 +53,11 @@ import { conversationContainsAnonymizedValues, conversationContainsContentReferences, } from './conversations/utils'; +import { + LastConversation, + useAssistantLastConversation, + useAssistantSpaceId, +} from './use_space_aware_context'; export const CONVERSATION_SIDE_PANEL_WIDTH = 220; @@ -89,12 +94,9 @@ const AssistantComponent: React.FC = ({ currentAppId, augmentMessageCodeBlocks, getComments, - getLastConversation, http, promptContexts, currentUserAvatar, - setLastConversation, - spaceId, contentReferencesVisible, showAnonymizedValues, setContentReferencesVisible, @@ -133,6 +135,13 @@ const AssistantComponent: React.FC = ({ http, }); const defaultConnector = useMemo(() => getDefaultConnector(connectors), [connectors]); + const spaceId = useAssistantSpaceId(); + const { getLastConversation, setLastConversation } = useAssistantLastConversation({ spaceId }); + const lastConversationFromLocalStorage = useMemo( + () => getLastConversation(), + [getLastConversation] + ); + const { currentConversation, currentSystemPrompt, @@ -150,7 +159,7 @@ const AssistantComponent: React.FC = ({ defaultConnector, spaceId, refetchCurrentUserConversations, - lastConversation: lastConversation ?? getLastConversation(lastConversation), + lastConversation: lastConversation ?? lastConversationFromLocalStorage, mayUpdateConversations: isFetchedConnectors && isFetchedCurrentUserConversations && isFetchedPrompts, setLastConversation, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.test.tsx index 6711a459ef77a..5d17cff23de1f 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.test.tsx @@ -229,12 +229,15 @@ describe('useCurrentConversation', () => { }), }); + expect(mockUseConversation.getConversation).toHaveBeenCalledWith(mockData.welcome_id.id, true); + await act(async () => { await result.current.handleOnConversationSelected({ cId: conversationId, }); }); + expect(mockUseConversation.getConversation).toHaveBeenCalledWith(conversationId, undefined); expect(result.current.currentConversation).toEqual(conversation); expect(result.current.currentSystemPrompt?.id).toBe('something-crazy'); }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.tsx index f37c9bdeb9e59..f1cb658269b7e 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_current_conversation/index.tsx @@ -14,13 +14,13 @@ import { } from '@tanstack/react-query'; import { ApiConfig, PromptResponse } from '@kbn/elastic-assistant-common'; import useLocalStorage from 'react-use/lib/useLocalStorage'; -import { LastConversation } from '../../assistant_context'; import { FetchConversationsResponse } from '../api'; import { AIConnector } from '../../connectorland/connector_selector'; import { getDefaultNewSystemPrompt, getDefaultSystemPrompt } from '../use_conversation/helpers'; import { useConversation } from '../use_conversation'; import { sleep } from '../helpers'; import { Conversation } from '../../..'; +import type { LastConversation } from '../use_space_aware_context'; export interface Props { allSystemPrompts: PromptResponse[]; @@ -34,7 +34,7 @@ export interface Props { refetchCurrentUserConversations: ( options?: RefetchOptions & RefetchQueryFilters ) => Promise, unknown>>; - setLastConversation: Dispatch>; + setLastConversation: (lastConversation: LastConversation) => void; } interface UseCurrentConversation { @@ -125,14 +125,18 @@ export const useCurrentConversation = ({ * @param isStreamRefetch - Are we refetching because stream completed? If so retry several times to ensure the message has updated on the server */ const refetchCurrentConversation = useCallback( - async ({ cId, isStreamRefetch = false }: { cId?: string; isStreamRefetch?: boolean } = {}) => { + async ({ + cId, + isStreamRefetch = false, + silent, + }: { cId?: string; isStreamRefetch?: boolean; silent?: boolean } = {}) => { if (cId === '') { return; } const cConversationId = cId ?? currentConversation?.id; if (cConversationId) { - let updatedConversation = await getConversation(cConversationId); + let updatedConversation = await getConversation(cConversationId, silent); let retries = 0; const maxRetries = 5; @@ -203,7 +207,17 @@ export const useCurrentConversation = ({ ); const handleOnConversationSelected = useCallback( - async ({ cId, cTitle, apiConfig }: { apiConfig?: ApiConfig; cId: string; cTitle?: string }) => { + async ({ + cId, + cTitle, + apiConfig, + silent, + }: { + apiConfig?: ApiConfig; + cId: string; + cTitle?: string; + silent?: boolean; + }) => { if (cId === '') { if ( currentAppId === 'securitySolutionUI' && @@ -228,7 +242,7 @@ export const useCurrentConversation = ({ } // refetch will set the currentConversation try { - await refetchCurrentConversation({ cId }); + await refetchCurrentConversation({ cId, silent }); setLastConversation({ id: cId, }); @@ -249,7 +263,11 @@ export const useCurrentConversation = ({ useEffect(() => { if (!mayUpdateConversations || !!currentConversation) return; - handleOnConversationSelected({ cId: lastConversation.id, cTitle: lastConversation.title }); + handleOnConversationSelected({ + cId: lastConversation.id, + cTitle: lastConversation.title, + silent: true, + }); }, [lastConversation, handleOnConversationSelected, currentConversation, mayUpdateConversations]); const handleOnConversationDeleted = useCallback( diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/index.tsx new file mode 100644 index 0000000000000..06f12af48a3b0 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/index.tsx @@ -0,0 +1,12 @@ +/* + * 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 { useAssistantSpaceId, AssistantSpaceIdProvider } from './use_space_id'; +import { useAssistantLastConversation, type LastConversation } from './use_last_conversation'; + +export { useAssistantSpaceId, AssistantSpaceIdProvider, useAssistantLastConversation }; +export type { LastConversation }; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.test.tsx new file mode 100644 index 0000000000000..81b0a4789385d --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 useLocalStorage from 'react-use/lib/useLocalStorage'; +import { renderHook } from '@testing-library/react'; +import { useAssistantLastConversation } from './use_last_conversation'; +import { + DEFAULT_ASSISTANT_NAMESPACE, + LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY, + LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY, +} from '../../assistant_context/constants'; + +jest.mock('react-use/lib/useLocalStorage', () => + jest.fn().mockReturnValue([{ id: '456' }, jest.fn()]) +); +const spaceId = 'test'; + +describe('useAssistantLastConversation', () => { + beforeEach(() => jest.clearAllMocks()); + + test('getLastConversation defaults to provided id', () => { + const { result } = renderHook(() => useAssistantLastConversation({ spaceId })); + const id = result.current.getLastConversation({ id: '123' }); + expect(id).toEqual({ id: '123' }); + }); + + test('getLastConversation uses local storage id when no id is provided ', () => { + const { result } = renderHook(() => useAssistantLastConversation({ spaceId })); + const id = result.current.getLastConversation(); + expect(id).toEqual({ id: '456' }); + }); + + test('getLastConversation defaults to empty id when no local storage id and no id is provided ', () => { + (useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]); + const { result } = renderHook(() => useAssistantLastConversation({ spaceId })); + const id = result.current.getLastConversation(); + expect(id).toEqual({ id: '' }); + }); + + test('getLastConversation defaults to empty id when title is provided and preserves title', () => { + (useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]); + const { result } = renderHook(() => useAssistantLastConversation({ spaceId })); + const id = result.current.getLastConversation({ title: 'something' }); + expect(id).toEqual({ id: '', title: 'something' }); + }); + + describe.each([ + { + expected: `${DEFAULT_ASSISTANT_NAMESPACE}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}.${spaceId}`, + }, + { + expected: `${DEFAULT_ASSISTANT_NAMESPACE}.${LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY}.${spaceId}`, + }, + ])('useLocalStorage is called with keys with correct spaceId', ({ expected }) => { + test(`local storage key: ${expected}`, () => { + renderHook(() => useAssistantLastConversation({ spaceId })); + expect(useLocalStorage).toBeCalledWith(expected); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.tsx new file mode 100644 index 0000000000000..c2cf9e9f0f99b --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_last_conversation.tsx @@ -0,0 +1,115 @@ +/* + * 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 { useCallback, useEffect, useMemo } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { BehaviorSubject } from 'rxjs'; +import { + DEFAULT_ASSISTANT_NAMESPACE, + LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY, + LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY, +} from '../../assistant_context/constants'; +import { SelectedConversation } from '../../assistant_context'; +export interface LastConversation { + id: string; + title?: string; +} + +const lastConversationSubject$ = new BehaviorSubject(null); +const localStorageLastConversationIdSubject$ = new BehaviorSubject(null); +export const useAssistantLastConversation = ({ + nameSpace = DEFAULT_ASSISTANT_NAMESPACE, + spaceId, +}: { + nameSpace?: string; + spaceId: string; +}): { + getLastConversation: (selectedConversation?: SelectedConversation) => LastConversation; + setLastConversation: (lastConversation: LastConversation) => void; +} => { + // Legacy fallback: used only if the new storage value is not yet set + const [localStorageLastConversationId] = useLocalStorage( + `${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}.${spaceId}` + ); + + const [localStorageLastConversation, setLocalStorageLastConversation] = + useLocalStorage( + `${nameSpace}.${LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY}.${spaceId}` + ); + + // Sync BehaviorSubject when localStorage changes + useEffect(() => { + if (lastConversationSubject$.getValue() !== localStorageLastConversation) { + lastConversationSubject$.next(localStorageLastConversation || null); + } + + if (localStorageLastConversationIdSubject$.getValue() !== localStorageLastConversationId) { + localStorageLastConversationIdSubject$.next(localStorageLastConversationId || null); + } + }, [localStorageLastConversation, localStorageLastConversationId]); + + const getLastConversation = useCallback( + (selectedConversation?: SelectedConversation): LastConversation => { + let nextConversation: LastConversation = { id: '' }; + const localStorageLastConversationValue = lastConversationSubject$.getValue(); + const localStorageLastConversationIdValue = localStorageLastConversationIdSubject$.getValue(); + + // Type guard to check if selectedConversation has a 'title' + if (selectedConversation && 'title' in selectedConversation) { + nextConversation = { + id: '', + title: selectedConversation.title, + }; + + return nextConversation; + } + + // If selectedConversation exists and has an 'id', return it with no 'title' + if (selectedConversation && 'id' in selectedConversation) { + nextConversation = { id: selectedConversation.id }; + return nextConversation; + } + + // Check if localStorageLastConversation has a 'title' + if (localStorageLastConversationValue && 'title' in localStorageLastConversationValue) { + nextConversation = { + id: '', + title: localStorageLastConversationValue.title, + }; + return nextConversation; + } + + // If localStorageLastConversation, return it + if (localStorageLastConversationValue && 'id' in localStorageLastConversationValue) { + nextConversation = localStorageLastConversationValue; + return nextConversation; + } + + // If localStorageLastConversationId exists, use it as 'id' + if (localStorageLastConversationIdValue) { + nextConversation = { id: localStorageLastConversationIdValue }; + return nextConversation; + } + + // Default to an empty 'id' + return nextConversation; + }, + [] + ); + + const setLastConversation = useCallback( + (newConversation: LastConversation) => { + setLocalStorageLastConversation(newConversation); // Save to localStorage + lastConversationSubject$.next(newConversation); // Emit latest value + }, + [setLocalStorageLastConversation] + ); + + return useMemo( + () => ({ getLastConversation, setLastConversation }), + [getLastConversation, setLastConversation] + ); +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_space_id.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_space_id.tsx new file mode 100644 index 0000000000000..491b3d5cbb11a --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant/use_space_aware_context/use_space_id.tsx @@ -0,0 +1,28 @@ +/* + * 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'; + +interface UseSpaceIdContext { + spaceId: string; +} +interface SpaceIdProviderProps extends UseSpaceIdContext { + children: React.ReactNode; +} + +const SpaceIdContext = React.createContext(undefined); + +export const AssistantSpaceIdProvider: React.FC = ({ children, spaceId }) => { + return {children}; +}; + +export const useAssistantSpaceId = () => { + const context = React.useContext(SpaceIdContext); + if (context === undefined) { + throw new Error('useSpaceId must be used within a AssistantSpaceIdProvider'); + } + return context.spaceId; +}; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.test.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.test.tsx index 8204fdfbfa005..6a73248a88df6 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.test.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.test.tsx @@ -8,16 +8,8 @@ import { renderHook } from '@testing-library/react'; import { useAssistantContext } from '.'; -import useLocalStorage from 'react-use/lib/useLocalStorage'; import { TestProviders } from '../mock/test_providers/test_providers'; -jest.mock('react-use/lib/useLocalStorage', () => - jest.fn().mockReturnValue([{ id: '456' }, jest.fn()]) -); -jest.mock('react-use/lib/useSessionStorage', () => - jest.fn().mockReturnValue([{ id: '456' }, jest.fn()]) -); - describe('AssistantContext', () => { beforeEach(() => jest.clearAllMocks()); @@ -35,30 +27,4 @@ describe('AssistantContext', () => { expect(result.current.http.fetch).toBeCalledWith(path); }); - - test('getLastConversation defaults to provided id', async () => { - const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); - const id = result.current.getLastConversation({ id: '123' }); - expect(id).toEqual({ id: '123' }); - }); - - test('getLastConversation uses local storage id when no id is provided ', async () => { - const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); - const id = result.current.getLastConversation(); - expect(id).toEqual({ id: '456' }); - }); - - test('getLastConversation defaults to empty id when no local storage id and no id is provided ', async () => { - (useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]); - const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); - const id = result.current.getLastConversation(); - expect(id).toEqual({ id: '' }); - }); - - test('getLastConversation defaults to empty id when title is provided and preserves title', async () => { - (useLocalStorage as jest.Mock).mockReturnValue([undefined, jest.fn()]); - const { result } = renderHook(useAssistantContext, { wrapper: TestProviders }); - const id = result.current.getLastConversation({ title: 'something' }); - expect(id).toEqual({ id: '', title: 'something' }); - }); }); diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx index 76d8a4b20bbff..07a747220d941 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -38,8 +38,6 @@ import { DEFAULT_ASSISTANT_NAMESPACE, DEFAULT_KNOWLEDGE_BASE_SETTINGS, KNOWLEDGE_BASE_LOCAL_STORAGE_KEY, - LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY, - LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY, SHOW_ANONYMIZED_VALUES_LOCAL_STORAGE_KEY, STREAMING_LOCAL_STORAGE_KEY, TRACE_OPTIONS_SESSION_STORAGE_KEY, @@ -49,10 +47,6 @@ import { ModalSettingsTabs } from '../assistant/settings/types'; import { AssistantNavLink } from './assistant_nav_link'; export type SelectedConversation = { id: string } | { title: string }; -export interface LastConversation { - id: string; - title?: string; -} export interface ShowAssistantOverlayProps { showOverlay: boolean; @@ -87,7 +81,6 @@ export interface AssistantProviderProps { title?: string; toasts?: IToasts; currentAppId: string; - spaceId: string; productDocBase: ProductDocBasePluginStart; userProfileService: UserProfileService; chrome: ChromeStart; @@ -117,7 +110,6 @@ export interface UseAssistantContext { http: HttpSetup; inferenceEnabled: boolean; knowledgeBase: KnowledgeBaseConfig; - getLastConversation: (selectedConversation?: SelectedConversation) => LastConversation; promptContexts: Record; navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise; nameSpace: string; @@ -129,7 +121,6 @@ export interface UseAssistantContext { setContentReferencesVisible: React.Dispatch>; setAssistantStreamingEnabled: React.Dispatch>; setKnowledgeBase: React.Dispatch>; - setLastConversation: React.Dispatch>; setSelectedSettingsTab: React.Dispatch>; setShowAssistantOverlay: (showAssistantOverlay: ShowAssistantOverlay) => void; showAssistantOverlay: ShowAssistantOverlay; @@ -138,7 +129,6 @@ export interface UseAssistantContext { langSmithProject: string; langSmithApiKey: string; }) => void; - spaceId: string; title: string; toasts: IToasts | undefined; traceOptions: TraceOptions; @@ -169,7 +159,6 @@ export const AssistantProvider: React.FC = ({ navigateToApp, nameSpace = DEFAULT_ASSISTANT_NAMESPACE, productDocBase, - spaceId, title = DEFAULT_ASSISTANT_TITLE, toasts, currentAppId, @@ -190,16 +179,6 @@ export const AssistantProvider: React.FC = ({ defaultTraceOptions ); - // Legacy fallback: used only if the new storage value is not yet set - const [localStorageLastConversationId] = useLocalStorage( - `${nameSpace}.${LAST_CONVERSATION_ID_LOCAL_STORAGE_KEY}` - ); - - const [localStorageLastConversation, setLocalStorageLastConversation] = - useLocalStorage( - `${nameSpace}.${LAST_SELECTED_CONVERSATION_LOCAL_STORAGE_KEY}` - ); - /** * Local storage for knowledge base configuration, prefixed by assistant nameSpace */ @@ -299,51 +278,6 @@ export const AssistantProvider: React.FC = ({ */ const codeBlockRef = useRef(() => {}); - const getLastConversation = useCallback( - (selectedConversation?: SelectedConversation): LastConversation => { - let nextConversation: LastConversation = { id: '' }; - // Type guard to check if selectedConversation has a 'title' - if (selectedConversation && 'title' in selectedConversation) { - nextConversation = { - id: '', - title: selectedConversation.title, - }; - return nextConversation; - } - - // If selectedConversation exists and has an 'id', return it with no 'title' - if (selectedConversation && 'id' in selectedConversation) { - nextConversation = { id: selectedConversation.id }; - return nextConversation; - } - - // Check if localStorageLastConversation has a 'title' - if (localStorageLastConversation && 'title' in localStorageLastConversation) { - nextConversation = { - id: '', - title: localStorageLastConversation.title, - }; - return nextConversation; - } - - // If localStorageLastConversation, return it - if (localStorageLastConversation && 'id' in localStorageLastConversation) { - nextConversation = localStorageLastConversation; - return nextConversation; - } - - // If localStorageLastConversationId exists, use it as 'id' - if (localStorageLastConversationId) { - nextConversation = { id: localStorageLastConversationId }; - return nextConversation; - } - - // Default to an empty 'id' - return nextConversation; - }, - [localStorageLastConversation, localStorageLastConversationId] - ); - // Fetch assistant capabilities const { data: assistantFeatures } = useCapabilities({ http, toasts }); @@ -388,13 +322,10 @@ export const AssistantProvider: React.FC = ({ setShowAssistantOverlay, setTraceOptions: setSessionStorageTraceOptions, showAssistantOverlay, - spaceId, title, toasts, traceOptions: sessionStorageTraceOptions, unRegisterPromptContext, - getLastConversation, - setLastConversation: setLocalStorageLastConversation, currentAppId, codeBlockRef, userProfileService, @@ -430,13 +361,10 @@ export const AssistantProvider: React.FC = ({ setContentReferencesVisible, setSessionStorageTraceOptions, showAssistantOverlay, - spaceId, title, toasts, sessionStorageTraceOptions, unRegisterPromptContext, - getLastConversation, - setLocalStorageLastConversation, currentAppId, codeBlockRef, userProfileService, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx index fea9a07aa9ff4..5674d1fcc7015 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/impl/mock/test_providers/test_providers.tsx @@ -18,6 +18,7 @@ import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; import { of } from 'rxjs'; import { AssistantProvider, AssistantProviderProps } from '../../assistant_context'; import { AssistantAvailability } from '../../assistant_context/types'; +import { AssistantSpaceIdProvider } from '../../assistant/use_space_aware_context'; interface Props { assistantAvailability?: AssistantAvailability; @@ -92,9 +93,8 @@ export const TestProvidersComponent: React.FC = ({ }} userProfileService={jest.fn() as unknown as UserProfileService} chrome={chrome} - spaceId="default" > - {children} + {children} diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts index f30dd5a8b0ba7..93aab8d6aeb61 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant/index.ts @@ -13,7 +13,7 @@ /** provides context (from the app) to the assistant, and injects Kibana services, like `http` */ export { AssistantProvider, useAssistantContext } from './impl/assistant_context'; -// Step 2: Add the `AssistantOverlay` component to your app. This component displays the assistant +// Step 2.1: Add the `AssistantOverlay` component to your app. This component displays the assistant // overlay in a modal, bound to a shortcut key: /** modal overlay for Elastic Assistant conversations */ @@ -25,6 +25,15 @@ export { AssistantOverlay } from './impl/assistant/assistant_overlay'; /** this component renders the Assistant without the modal overlay to, for example, render it in a Timeline tab */ export { Assistant } from './impl/assistant'; +// Step 2.2: Provide spaceId to `AssistantSpaceIdProvider` +// The spaceId here will be used to fetch the assistant data from localstorage. +// So make sure not to provide null, undefined, or any fallback spaceId. +// Only render the `AssistantSpaceIdProvider` component when the spaceId is available. +export { + AssistantSpaceIdProvider, + useAssistantLastConversation, +} from './impl/assistant/use_space_aware_context'; + // Step 3: Wherever you want to bring context into the assistant, use the any combination of the following // components and hooks: // - `NewChat` component diff --git a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx index 370a8df4b09ea..29b471ba72693 100644 --- a/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx +++ b/x-pack/solutions/security/packages/ecs-data-quality-dashboard/impl/data_quality_panel/mock/test_providers/test_providers.tsx @@ -93,7 +93,6 @@ const TestExternalProvidersComponent: React.FC = ({ currentAppId={'securitySolutionUI'} userProfileService={jest.fn() as unknown as UserProfileService} chrome={chrome} - spaceId="default" > {children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.test.tsx index eb9adf234e9fd..37a0db27705f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.test.tsx @@ -17,9 +17,13 @@ jest.mock('@kbn/elastic-assistant', () => ({ useAssistantContext: () => ({ assistantAvailability: mockAssistantAvailability(), }), + AssistantSpaceIdProvider: ({ children }: { children: React.ReactNode }) => <>{children}, // Mock it as a passthrough })); jest.mock('../common/hooks/use_experimental_features'); +jest.mock('../common/hooks/use_space_id', () => ({ + useSpaceId: () => 'space-id', +})); describe('AssistantOverlay', () => { const queryClient = new QueryClient({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.tsx index 6f0da894dd728..5bacf5d3eca59 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/overlay.tsx @@ -8,14 +8,21 @@ import React from 'react'; import { AssistantOverlay as ElasticAssistantOverlay, useAssistantContext, + AssistantSpaceIdProvider, } from '@kbn/elastic-assistant'; +import { useSpaceId } from '../common/hooks/use_space_id'; export const AssistantOverlay: React.FC = () => { const { assistantAvailability } = useAssistantContext(); + const spaceId = useSpaceId(); - if (!assistantAvailability.hasAssistantPrivilege) { + if (!assistantAvailability.hasAssistantPrivilege || !spaceId) { return null; } - return ; + return ( + + + + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx index 507f20c737d2a..94baa67ea6e0e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx @@ -24,7 +24,6 @@ import type { HttpSetup } from '@kbn/core-http-browser'; import type { Message } from '@kbn/elastic-assistant-common'; import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; import useObservable from 'react-use/lib/useObservable'; -import { useSpaceId } from '../common/hooks/use_space_id'; import { APP_ID } from '../../common'; import { useBasePath, useKibana } from '../common/lib/kibana'; import { useAssistantTelemetry } from './use_assistant_telemetry'; @@ -146,7 +145,6 @@ export const AssistantProvider: FC> = ({ children }) chrome, productDocBase, } = useKibana().services; - const spaceId = useSpaceId(); let inferenceEnabled = false; try { @@ -236,7 +234,6 @@ export const AssistantProvider: FC> = ({ children }) inferenceEnabled={inferenceEnabled} navigateToApp={navigateToApp} productDocBase={productDocBase} - spaceId={spaceId ?? 'default'} title={ASSISTANT_TITLE} toasts={toasts} currentAppId={currentAppId ?? 'securitySolutionUI'} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx index 93a2a2ece22ba..9440c0dccde70 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/mock/mock_assistant_provider.tsx @@ -64,7 +64,6 @@ export const MockAssistantProviderComponent: React.FC = ({ }} userProfileService={mockUserProfileService} chrome={chrome} - spaceId="default" > {children} diff --git a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx index ab183707dda69..5f7d081d91ce0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/onboarding/components/onboarding_body/cards/assistant/assistant_card.tsx @@ -8,7 +8,11 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink } from '@elastic/eui'; import { css } from '@emotion/css'; -import { useAssistantContext, type Conversation } from '@kbn/elastic-assistant'; +import { + useAssistantContext, + type Conversation, + useAssistantLastConversation, +} from '@kbn/elastic-assistant'; import { useCurrentConversation } from '@kbn/elastic-assistant/impl/assistant/use_current_conversation'; import { useDataStreamApis } from '@kbn/elastic-assistant/impl/assistant/use_data_stream_apis'; import { getDefaultConnector } from '@kbn/elastic-assistant/impl/assistant/helpers'; @@ -61,9 +65,8 @@ export const AssistantCard: OnboardingCardComponent = ({ const { http, assistantAvailability: { isAssistantEnabled }, - getLastConversation, - setLastConversation, } = useAssistantContext(); + const { getLastConversation, setLastConversation } = useAssistantLastConversation({ spaceId }); const { allSystemPrompts, conversations,