diff --git a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_flyout.tsx b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_flyout.tsx index f7d8b3b20c433..41e73e7d6e456 100644 --- a/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_flyout.tsx +++ b/x-pack/platform/packages/shared/kbn-ai-assistant/src/chat/chat_flyout.tsx @@ -44,6 +44,7 @@ export function ChatFlyout({ initialTitle, initialMessages, initialFlyoutPositionMode, + onFlyoutPositionModeChange, isOpen, onClose, navigateToConversation, @@ -52,6 +53,7 @@ export function ChatFlyout({ initialTitle: string; initialMessages: Message[]; initialFlyoutPositionMode?: FlyoutPositionMode; + onFlyoutPositionModeChange?: (next: FlyoutPositionMode) => void; isOpen: boolean; onClose: () => void; navigateToConversation?: (conversationId?: string) => void; @@ -137,6 +139,7 @@ export function ChatFlyout({ const handleToggleFlyoutPositionMode = (newFlyoutPositionMode: FlyoutPositionMode) => { setFlyoutPositionMode(newFlyoutPositionMode); + onFlyoutPositionModeChange?.(newFlyoutPositionMode); }; return isOpen ? ( diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/components/nav_control/index.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/components/nav_control/index.tsx index e43a9ea5f4527..1d23eb2b19b6c 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/components/nav_control/index.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/components/nav_control/index.tsx @@ -12,7 +12,12 @@ import { v4 } from 'uuid'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; import { CoreStart } from '@kbn/core-lifecycle-browser'; -import { AIAssistantAppService, useAIAssistantAppService, ChatFlyout } from '@kbn/ai-assistant'; +import { + AIAssistantAppService, + useAIAssistantAppService, + ChatFlyout, + FlyoutPositionMode, +} from '@kbn/ai-assistant'; import { AssistantIcon } from '@kbn/ai-assistant-icon'; import { useKibana } from '../../hooks/use_kibana'; import { useTheme } from '../../hooks/use_theme'; @@ -20,6 +25,7 @@ import { useNavControlScreenContext } from '../../hooks/use_nav_control_screen_c import { SharedProviders } from '../../utils/shared_providers'; import { ObservabilityAIAssistantAppPluginStartDependencies } from '../../types'; import { useNavControlScope } from '../../hooks/use_nav_control_scope'; +import { useLocalStorage } from '../../hooks/use_local_storage'; interface NavControlWithProviderDeps { appService: AIAssistantAppService; @@ -62,11 +68,21 @@ export function NavControl({ isServerless }: { isServerless?: boolean }) { }, } = useKibana(); - const [hasBeenOpened, setHasBeenOpened] = useState(false); - useNavControlScreenContext(); useNavControlScope(); + const [flyoutSettings, setFlyoutSettings] = useLocalStorage( + 'observabilityAIAssistant.flyoutSettings', + { + mode: FlyoutPositionMode.OVERLAY, + isOpen: false, + } + ); + + const [isOpen, setIsOpen] = useState(flyoutSettings.isOpen); + const [hasBeenOpened, setHasBeenOpened] = useState(isOpen); + const keyRef = useRef(v4()); + const chatService = useAbortableAsync( ({ signal }) => { return hasBeenOpened @@ -90,21 +106,18 @@ export function NavControl({ isServerless }: { isServerless?: boolean }) { [service, hasBeenOpened, notifications.toasts] ); - const [isOpen, setIsOpen] = useState(false); - - const keyRef = useRef(v4()); - useEffect(() => { const conversationSubscription = service.conversations.predefinedConversation$.subscribe(() => { keyRef.current = v4(); setHasBeenOpened(true); + setFlyoutSettings((prev) => ({ ...prev, isOpen: true })); setIsOpen(true); }); return () => { conversationSubscription.unsubscribe(); }; - }, [service.conversations.predefinedConversation$]); + }, [service.conversations.predefinedConversation$, setFlyoutSettings]); const { messages, title, hideConversationList } = useObservable( service.conversations.predefinedConversation$ @@ -186,9 +199,14 @@ export function NavControl({ isServerless }: { isServerless?: boolean }) { isOpen={isOpen} initialMessages={messages} initialTitle={title ?? ''} + initialFlyoutPositionMode={flyoutSettings.mode} onClose={() => { + setFlyoutSettings((prev) => ({ ...prev, isOpen: false })); setIsOpen(false); }} + onFlyoutPositionModeChange={(next) => { + setFlyoutSettings((prev) => ({ ...prev, mode: next })); + }} navigateToConversation={(conversationId?: string) => { application.navigateToUrl( http.basePath.prepend( diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.test.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.test.ts index ea4ed05e36b66..b03e16f03759a 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.test.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.test.ts @@ -10,7 +10,7 @@ import { useLocalStorage } from './use_local_storage'; describe('useLocalStorage', () => { const key = 'testKey'; - const defaultValue = 'defaultValue'; + const defaultValue: string = 'defaultValue'; beforeEach(() => { localStorage.clear(); @@ -44,17 +44,6 @@ describe('useLocalStorage', () => { expect(JSON.parse(localStorage.getItem(key) || '')).toBe(newValue); }); - it('should remove the value from local storage when the value is undefined', () => { - const { result } = renderHook(() => useLocalStorage(key, defaultValue)); - const [, saveToStorage] = result.current; - - act(() => { - saveToStorage(undefined as unknown as string); - }); - - expect(localStorage.getItem(key)).toBe(null); - }); - it('should listen for storage events to window, and remove the listener upon unmount', () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.ts index ea9e13163e4b0..88ce3e88f61bb 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/hooks/use_local_storage.ts @@ -5,45 +5,74 @@ * 2.0. */ -import { useState, useEffect, useMemo, useCallback } from 'react'; - -export function useLocalStorage(key: string, defaultValue: T) { - // This is necessary to fix a race condition issue. - // It guarantees that the latest value will be always returned after the value is updated - const [storageUpdate, setStorageUpdate] = useState(0); - - const item = useMemo(() => { - return getFromStorage(key, defaultValue); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, storageUpdate, defaultValue]); - - const saveToStorage = useCallback( - (value: T) => { - if (value === undefined) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, JSON.stringify(value)); - setStorageUpdate(storageUpdate + 1); - } - }, - [key, storageUpdate] +import { useEffect, useMemo, useRef, useState } from 'react'; + +const LOCAL_STORAGE_UPDATE_EVENT_TYPE = 'customLocalStorage'; + +export function useLocalStorage( + key: string, + defaultValue: T | (() => T) +): [T, SetValue] { + const defaultValueRef = useRef( + typeof defaultValue === 'function' ? defaultValue() : defaultValue ); + const [value, setValue] = useState(() => getFromStorage(key, defaultValueRef.current)); + + const valueRef = useRef(value); + valueRef.current = value; + + const setter = useMemo(() => { + return (valueOrCallback: T | ((prev: T) => T)) => { + const nextValue = + typeof valueOrCallback === 'function' ? valueOrCallback(valueRef.current) : valueOrCallback; + + window.localStorage.setItem(key, JSON.stringify(nextValue)); + + /* + * This is necessary to trigger the event listener in the same + * window context. + */ + window.dispatchEvent( + new window.CustomEvent<{ key: string }>(LOCAL_STORAGE_UPDATE_EVENT_TYPE, { + detail: { key }, + }) + ); + }; + }, [key]); + useEffect(() => { - function onUpdate(event: StorageEvent) { + function updateValueFromStorage() { + setValue(getFromStorage(key, defaultValueRef.current)); + } + + function onStorageEvent(event: StorageEvent) { if (event.key === key) { - setStorageUpdate(storageUpdate + 1); + updateValueFromStorage(); + } + } + + function onCustomLocalStorageEvent(event: Event) { + if (event instanceof window.CustomEvent && event.detail?.key === key) { + updateValueFromStorage(); } } - window.addEventListener('storage', onUpdate); + + window.addEventListener('storage', onStorageEvent); + window.addEventListener(LOCAL_STORAGE_UPDATE_EVENT_TYPE, onCustomLocalStorageEvent); + return () => { - window.removeEventListener('storage', onUpdate); + window.removeEventListener('storage', onStorageEvent); + window.removeEventListener(LOCAL_STORAGE_UPDATE_EVENT_TYPE, onCustomLocalStorageEvent); }; - }, [key, setStorageUpdate, storageUpdate]); + }, [key]); - return useMemo(() => [item, saveToStorage] as const, [item, saveToStorage]); + return [value, setter]; } +type AllowedValue = string | number | boolean | Record | any[]; +type SetValue = (next: T | ((prev: T) => T)) => void; + function getFromStorage(keyName: string, defaultValue: T) { const storedItem = window.localStorage.getItem(keyName);