diff --git a/public/chat_flyout.test.tsx b/public/chat_flyout.test.tsx index 7bf2100a..881ad5a3 100644 --- a/public/chat_flyout.test.tsx +++ b/public/chat_flyout.test.tsx @@ -54,7 +54,6 @@ describe('', () => { overrideComponent={null} flyoutProps={{}} flyoutFullScreen={false} - toggleFlyoutFullScreen={jest.fn()} /> ); expect(screen.getByLabelText('chat panel').classList).not.toContain('llm-chat-hidden'); @@ -74,7 +73,6 @@ describe('', () => { overrideComponent={null} flyoutProps={{}} flyoutFullScreen={false} - toggleFlyoutFullScreen={jest.fn()} /> ); expect(screen.getByLabelText('chat panel').classList).toContain('llm-chat-hidden'); @@ -94,7 +92,6 @@ describe('', () => { overrideComponent={null} flyoutProps={{}} flyoutFullScreen={false} - toggleFlyoutFullScreen={jest.fn()} /> ); @@ -117,7 +114,6 @@ describe('', () => { overrideComponent={null} flyoutProps={{}} flyoutFullScreen={false} - toggleFlyoutFullScreen={jest.fn()} /> ); @@ -139,7 +135,6 @@ describe('', () => { overrideComponent={null} flyoutProps={{}} flyoutFullScreen={true} // fullscreen - toggleFlyoutFullScreen={jest.fn()} /> ); diff --git a/public/chat_flyout.tsx b/public/chat_flyout.tsx index df5f3405..a4a76658 100644 --- a/public/chat_flyout.tsx +++ b/public/chat_flyout.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiFlyout, EuiFlyoutHeader, EuiResizableContainer } from '@elastic/eui'; +import { EuiResizableContainer } from '@elastic/eui'; import cs from 'classnames'; import React, { useRef } from 'react'; import { useChatContext } from './contexts/chat_context'; @@ -16,9 +16,7 @@ import { TAB_ID } from './utils/constants'; interface ChatFlyoutProps { flyoutVisible: boolean; overrideComponent: React.ReactNode | null; - flyoutProps: Partial>; flyoutFullScreen: boolean; - toggleFlyoutFullScreen: () => void; } export const ChatFlyout = (props: ChatFlyoutProps) => { @@ -81,26 +79,15 @@ export const ChatFlyout = (props: ChatFlyoutProps) => { const rightPanelSize = getRightPanelSize(); return ( - chatContext.setFlyoutVisible(false)} - {...props.flyoutProps} > <> - - - +
+ +
{props.overrideComponent} @@ -146,6 +133,6 @@ export const ChatFlyout = (props: ChatFlyoutProps) => { )} -
+ ); }; diff --git a/public/chat_header_button.test.tsx b/public/chat_header_button.test.tsx index ad4825e4..b5aa5254 100644 --- a/public/chat_header_button.test.tsx +++ b/public/chat_header_button.test.tsx @@ -10,6 +10,8 @@ import { HeaderChatButton } from './chat_header_button'; import { applicationServiceMock } from '../../../src/core/public/mocks'; import { AssistantActions } from './types'; import { BehaviorSubject } from 'rxjs'; +import * as coreContextExports from './contexts/core_context'; +import { MountWrapper } from '../../../src/core/public/utils'; let mockSend: jest.Mock; let mockLoadChat: jest.Mock; @@ -32,18 +34,7 @@ jest.mock('./hooks/use_chat_actions', () => { jest.mock('./chat_flyout', () => { return { - ChatFlyout: ({ - toggleFlyoutFullScreen, - flyoutFullScreen, - }: { - toggleFlyoutFullScreen: () => void; - flyoutFullScreen: boolean; - }) => ( -
- -

{flyoutFullScreen ? 'fullscreen mode' : 'dock-right mode'}

-
- ), + ChatFlyout: () =>
, }; }); @@ -57,12 +48,43 @@ jest.mock('./services', () => { }; }); +// mock sidecar open,hide and show +jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ + overlays: { + // @ts-ignore + sidecar: () => { + const attachElement = document.createElement('div'); + attachElement.id = 'sidecar-mock-div'; + return { + open: (mountPoint) => { + document.body.appendChild(attachElement); + render(, { + container: attachElement, + }); + }, + hide: () => { + const element = document.getElementById('sidecar-mock-div'); + if (element) { + element.style.display = 'none'; + } + }, + show: () => { + const element = document.getElementById('sidecar-mock-div'); + if (element) { + element.style.display = 'block'; + } + }, + }; + }, + }, +}); + describe('', () => { afterEach(() => { jest.clearAllMocks(); }); - it('should open chat flyout and send the initial message', () => { + it('should open chat flyout, send the initial message and hide and show flyout', () => { const applicationStart = { ...applicationServiceMock.createStartContract(), currentAppId$: new BehaviorSubject(''), @@ -106,24 +128,14 @@ describe('', () => { // the input value is cleared after pressing enter expect(screen.getByLabelText('chat input')).toHaveValue(''); expect(screen.getByLabelText('chat input')).not.toHaveFocus(); - }); - - it('should toggle chat flyout size', () => { - render( - - ); - fireEvent.click(screen.getByLabelText('toggle chat flyout icon')); - expect(screen.queryByText('dock-right mode')).toBeInTheDocument(); - fireEvent.click(screen.getByText('toggle chat flyout fullscreen')); - expect(screen.queryByText('fullscreen mode')).toBeInTheDocument(); + // sidecar show + const toggleButton = screen.getByLabelText('toggle chat flyout icon'); + fireEvent.click(toggleButton); + expect(screen.queryByLabelText('chat flyout mock')).not.toBeVisible(); + // sidecar hide + fireEvent.click(toggleButton); + expect(screen.queryByLabelText('chat flyout mock')).toBeVisible(); }); it('should focus in chat input when click and press Escape should blur', () => { diff --git a/public/chat_header_button.tsx b/public/chat_header_button.tsx index 4aee4d14..2f83e388 100644 --- a/public/chat_header_button.tsx +++ b/public/chat_header_button.tsx @@ -7,7 +7,7 @@ import { EuiBadge, EuiFieldText, EuiIcon } from '@elastic/eui'; import classNames from 'classnames'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffectOnce } from 'react-use'; -import { ApplicationStart } from '../../../src/core/public'; +import { ApplicationStart, SIDECAR_DOCKED_MODE } from '../../../src/core/public'; // TODO: Replace with getChrome().logos.Chat.url import chatIcon from './assets/chat.svg'; import { getIncontextInsightRegistry } from './services'; @@ -17,7 +17,13 @@ import { SetContext } from './contexts/set_context'; import { ChatStateProvider } from './hooks'; import './index.scss'; import { ActionExecutor, AssistantActions, MessageRenderer, TabId, UserAccount } from './types'; -import { TAB_ID } from './utils/constants'; +import { + TAB_ID, + DEFAULT_SIDECAR_DOCKED_MODE, + DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, +} from './utils/constants'; +import { useCore } from './contexts/core_context'; +import { MountPointPortal } from '../../../src/plugins/opensearch_dashboards_react/public'; interface HeaderChatButtonProps { application: ApplicationStart; @@ -39,23 +45,20 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { const [selectedTabId, setSelectedTabId] = useState(TAB_ID.CHAT); const [preSelectedTabId, setPreSelectedTabId] = useState(undefined); const [interactionId, setInteractionId] = useState(undefined); - const [chatSize, setChatSize] = useState('dock-right'); const [inputFocus, setInputFocus] = useState(false); - const flyoutFullScreen = chatSize === 'fullscreen'; const inputRef = useRef(null); const registry = getIncontextInsightRegistry(); - if (!flyoutLoaded && flyoutVisible) flyoutLoaded = true; + const [sidecarDockedMode, setSidecarDockedMode] = useState(DEFAULT_SIDECAR_DOCKED_MODE); + const core = useCore(); + const flyoutFullScreen = sidecarDockedMode === SIDECAR_DOCKED_MODE.TAKEOVER; + const flyoutMountPoint = useRef(null); useEffectOnce(() => { const subscription = props.application.currentAppId$.subscribe((id) => setAppId(id)); return () => subscription.unsubscribe(); }); - const toggleFlyoutFullScreen = useCallback(() => { - setChatSize(flyoutFullScreen ? 'dock-right' : 'fullscreen'); - }, [flyoutFullScreen, setChatSize]); - const chatContextValue: IChatContext = useMemo( () => ({ appId, @@ -79,6 +82,8 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { setTitle, interactionId, setInteractionId, + sidecarDockedMode, + setSidecarDockedMode, }), [ appId, @@ -95,6 +100,8 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { setTitle, interactionId, setInteractionId, + sidecarDockedMode, + setSidecarDockedMode, ] ); @@ -119,12 +126,36 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { } }; + useEffect(() => { + if (!flyoutLoaded && flyoutVisible) { + const mountPoint = flyoutMountPoint.current; + if (mountPoint) { + core.overlays.sidecar().open(mountPoint, { + className: 'chatbot-sidecar', + config: { + dockedMode: SIDECAR_DOCKED_MODE.RIGHT, + paddingSize: DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, + }, + }); + flyoutLoaded = true; + } + } else if (flyoutLoaded && flyoutVisible) { + core.overlays.sidecar().show(); + } else if (flyoutLoaded && !flyoutVisible) { + core.overlays.sidecar().hide(); + } + }, [flyoutVisible, flyoutLoaded]); + const onKeyUp = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { inputRef.current?.blur(); } }; + const setMountPoint = useCallback((mountPoint) => { + flyoutMountPoint.current = mountPoint; + }, []); + useEffect(() => { if (!props.userHasAccess) { return; @@ -206,21 +237,20 @@ export const HeaderChatButton = (props: HeaderChatButtonProps) => { } disabled={!props.userHasAccess} /> + + + + {/* Chatbot's DOM consists of two parts. One part is the headerButton inside the OSD, and the other part is the flyout/sidecar outside the OSD. This is to allow the context of the two parts to be shared. */} + + + + +
- - - - {flyoutLoaded ? ( - - ) : null} - - ); }; diff --git a/public/components/__snapshots__/sidecar_icon_menu.test.tsx.snap b/public/components/__snapshots__/sidecar_icon_menu.test.tsx.snap new file mode 100644 index 00000000..61c9ed86 --- /dev/null +++ b/public/components/__snapshots__/sidecar_icon_menu.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +HTMLCollection [ +
+
+
+ +
+
+
, +] +`; diff --git a/public/components/sidecar_icon_menu.test.tsx b/public/components/sidecar_icon_menu.test.tsx new file mode 100644 index 00000000..655a6af0 --- /dev/null +++ b/public/components/sidecar_icon_menu.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { SidecarIconMenu } from './sidecar_icon_menu'; +import * as chatContextExports from './../contexts/chat_context'; +import { SIDECAR_DOCKED_MODE } from '../../../../src/core/public'; +import * as coreContextExports from '../contexts/core_context'; +import { + DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, + DEFAULT_SIDECAR_TAKEOVER_PADDING_TOP_SIZE, +} from '../utils/constants'; + +const setSidecarDockedMode = jest.fn(); +const setSidecarConfig = jest.fn(); + +const setup = (sidecarDockedMode = SIDECAR_DOCKED_MODE.RIGHT) => { + const useChatContextMock = { + conversationId: '1', + title: 'foo', + setConversationId: jest.fn(), + setTitle: jest.fn(), + setFlyoutVisible: jest.fn(), + setSelectedTabId: jest.fn(), + setFlyoutComponent: jest.fn(), + sidecarDockedMode, + setSidecarDockedMode, + }; + jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); + + jest.spyOn(coreContextExports, 'useCore').mockReturnValue({ + overlays: { + sidecar: () => { + return { + setSidecarConfig, + }; + }, + }, + }); + + const renderResult = render(); + + return { + renderResult, + useChatContextMock, + }; +}; + +describe(' spec', () => { + it('renders the component', async () => { + setup(); + expect(document.body.children).toMatchSnapshot(); + }); + + it('clicks icon and set a new mode', async () => { + setup(); + const icon = screen.getByLabelText('setSidecarMode'); + fireEvent.click(icon); + const leftIcon = screen.getByTestId('sidecar-mode-icon-menu-item-left'); + fireEvent.click(leftIcon); + expect(setSidecarConfig).toHaveBeenCalledWith({ + dockedMode: SIDECAR_DOCKED_MODE.LEFT, + }); + expect(setSidecarDockedMode).toHaveBeenCalledWith(SIDECAR_DOCKED_MODE.LEFT); + }); + + it('set same mode will return', async () => { + setup(); + const icon = screen.getByLabelText('setSidecarMode'); + fireEvent.click(icon); + const rightIcon = screen.getByTestId('sidecar-mode-icon-menu-item-right'); + fireEvent.click(rightIcon); + expect(setSidecarDockedMode).not.toHaveBeenCalled(); + }); + + it('set takeover mode will set a new default size based on window', async () => { + setup(); + const icon = screen.getByLabelText('setSidecarMode'); + fireEvent.click(icon); + const takeoverIcon = screen.getByTestId('sidecar-mode-icon-menu-item-takeover'); + fireEvent.click(takeoverIcon); + const defaultTakeOverSize = window.innerHeight - DEFAULT_SIDECAR_TAKEOVER_PADDING_TOP_SIZE; + expect(setSidecarConfig).toHaveBeenCalledWith({ + dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER, + paddingSize: defaultTakeOverSize, + }); + expect(setSidecarDockedMode).toHaveBeenCalledWith(SIDECAR_DOCKED_MODE.TAKEOVER); + }); + + it('set default left or right size when switching from takeover', async () => { + setup(SIDECAR_DOCKED_MODE.TAKEOVER); + const icon = screen.getByLabelText('setSidecarMode'); + fireEvent.click(icon); + const leftIcon = screen.getByTestId('sidecar-mode-icon-menu-item-left'); + fireEvent.click(leftIcon); + expect(setSidecarConfig).toHaveBeenCalledWith({ + dockedMode: SIDECAR_DOCKED_MODE.LEFT, + paddingSize: DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, + }); + expect(setSidecarDockedMode).toHaveBeenCalledWith(SIDECAR_DOCKED_MODE.LEFT); + }); +}); diff --git a/public/components/sidecar_icon_menu.tsx b/public/components/sidecar_icon_menu.tsx new file mode 100644 index 00000000..0799740b --- /dev/null +++ b/public/components/sidecar_icon_menu.tsx @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiContextMenuItem, EuiContextMenuPanel, EuiPopover, EuiButtonIcon } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useCore } from '../contexts/core_context'; +import { ISidecarConfig, SIDECAR_DOCKED_MODE } from '../../../../src/core/public'; +import { useChatContext } from '../contexts/chat_context'; +import { + DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, + DEFAULT_SIDECAR_TAKEOVER_PADDING_TOP_SIZE, +} from '../utils/constants'; + +const ALL_SIDECAR_DIRECTIONS: Array<{ + mode: ISidecarConfig['dockedMode']; + name: string; + icon: string; +}> = [ + { + mode: SIDECAR_DOCKED_MODE.RIGHT, + name: 'Dock Right', + icon: 'dockedRight', + }, + { + mode: SIDECAR_DOCKED_MODE.LEFT, + name: 'Dock Left', + icon: 'dockedLeft', + }, + { + mode: SIDECAR_DOCKED_MODE.TAKEOVER, + name: 'Dock Full Width', + icon: 'dockedTakeover', + }, +]; + +export const SidecarIconMenu = () => { + const [isPopoverOpen, setPopoverOpen] = useState(false); + const { sidecarDockedMode, setSidecarDockedMode } = useChatContext(); + const core = useCore(); + + const onButtonClick = useCallback(() => { + setPopoverOpen((flag) => !flag); + }, []); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, []); + + const setDockedMode = useCallback( + (mode: ISidecarConfig['dockedMode']) => { + const previousMode = sidecarDockedMode; + if (previousMode === mode) { + return; + } else { + if (mode === SIDECAR_DOCKED_MODE.TAKEOVER) { + const defaultTakeOverSize = + window.innerHeight - DEFAULT_SIDECAR_TAKEOVER_PADDING_TOP_SIZE; + core.overlays.sidecar().setSidecarConfig({ + dockedMode: mode, + paddingSize: defaultTakeOverSize, + }); + } else { + let newConfig; + if (previousMode !== SIDECAR_DOCKED_MODE.TAKEOVER) { + // Maintain the same panel sidecar width when switching between both dock left and dock right. + newConfig = { + dockedMode: mode, + }; + } else { + newConfig = { + dockedMode: mode, + paddingSize: DEFAULT_SIDECAR_LEFT_OR_RIGHT_SIZE, + }; + } + core.overlays.sidecar().setSidecarConfig(newConfig); + } + setSidecarDockedMode(mode); + } + }, + [setSidecarDockedMode, sidecarDockedMode] + ); + + const menuItems = useMemo( + () => + ALL_SIDECAR_DIRECTIONS.map(({ name, icon, mode }) => ( + { + closePopover(); + setDockedMode(mode); + }} + icon={sidecarDockedMode === mode ? 'check' : icon} + data-test-subj={`sidecar-mode-icon-menu-item-${mode}`} + > + {name} + + )), + [sidecarDockedMode, closePopover] + ); + + const selectMenuItemIndex = useMemo(() => { + return ALL_SIDECAR_DIRECTIONS.findIndex((item) => item.mode === sidecarDockedMode); + }, [sidecarDockedMode]); + + return ( + <> + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + + ); +}; diff --git a/public/contexts/chat_context.tsx b/public/contexts/chat_context.tsx index 8b43fb11..f7bc516c 100644 --- a/public/contexts/chat_context.tsx +++ b/public/contexts/chat_context.tsx @@ -5,6 +5,7 @@ import React, { useContext } from 'react'; import { ActionExecutor, UserAccount, TabId, MessageRenderer } from '../types'; +import { ISidecarConfig } from '../../../../src/core/public'; export interface IChatContext { appId?: string; @@ -25,6 +26,8 @@ export interface IChatContext { setTitle: React.Dispatch>; interactionId?: string; setInteractionId: React.Dispatch>; + sidecarDockedMode: ISidecarConfig['dockedMode']; + setSidecarDockedMode: React.Dispatch>; } export const ChatContext = React.createContext(null); diff --git a/public/index.scss b/public/index.scss index 127c2370..733b86f4 100644 --- a/public/index.scss +++ b/public/index.scss @@ -67,6 +67,7 @@ } .llm-chat-flyout { + height: 100%; .euiFlyoutFooter { background: transparent; } @@ -81,7 +82,7 @@ } } -.euiFlyoutHeader.llm-chat-flyout-header { +.llm-chat-flyout-header { padding-top: 4px; } @@ -91,6 +92,10 @@ border-radius: 8px; } +.llm-chat-flyout-footer { + padding-bottom: 24px; +} + .llm-chat-bubble-wrapper { .llm-chat-action-buttons-hidden { opacity: 0; diff --git a/public/tabs/__tests__/chat_window_header.test.tsx b/public/tabs/__tests__/chat_window_header.test.tsx index b17800cd..5c527ef4 100644 --- a/public/tabs/__tests__/chat_window_header.test.tsx +++ b/public/tabs/__tests__/chat_window_header.test.tsx @@ -6,18 +6,16 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; -import { ChatWindowHeader, ChatWindowHeaderProps } from '../chat_window_header'; +import { ChatWindowHeader } from '../chat_window_header'; import * as chatContextExports from '../../contexts/chat_context'; import { TabId } from '../../types'; +import { SIDECAR_DOCKED_MODE } from '../../../../../src/core/public'; jest.mock('../../components/chat_window_header_title', () => { return { ChatWindowHeaderTitle: () =>
OpenSearch Assistant
}; }); -const setup = ({ - selectedTabId, - ...props -}: Partial & { selectedTabId?: TabId } = {}) => { +const setup = ({ selectedTabId }: { selectedTabId?: TabId } = {}) => { const useChatContextMock = { conversationId: '1', title: 'foo', @@ -27,11 +25,10 @@ const setup = ({ setFlyoutVisible: jest.fn(), setSelectedTabId: jest.fn(), setFlyoutComponent: jest.fn(), + sidecarDockedMode: SIDECAR_DOCKED_MODE.RIGHT, }; jest.spyOn(chatContextExports, 'useChatContext').mockReturnValue(useChatContextMock); - const renderResult = render( - - ); + const renderResult = render(); return { renderResult, @@ -40,12 +37,12 @@ const setup = ({ }; describe('', () => { - it('should render title, history, fullscreen and close button', () => { + it('should render title, history, setSidecarMode and close button', () => { const { renderResult } = setup(); expect(renderResult.getByText('OpenSearch Assistant')).toBeInTheDocument(); expect(renderResult.getByLabelText('history')).toBeInTheDocument(); - expect(renderResult.getByLabelText('fullScreen')).toBeInTheDocument(); + expect(renderResult.getByLabelText('setSidecarMode')).toBeInTheDocument(); expect(renderResult.getByLabelText('close')).toBeInTheDocument(); }); @@ -84,16 +81,4 @@ describe('', () => { fireEvent.click(renderResult.getByLabelText('history')); expect(useChatContextMock.setSelectedTabId).toHaveBeenLastCalledWith('history'); }); - - it('should call toggleFullScreen after fullScreen clicked', () => { - const toggleFlyoutFullScreenMock = jest.fn(); - const { renderResult } = setup({ - flyoutFullScreen: true, - toggleFlyoutFullScreen: toggleFlyoutFullScreenMock, - }); - - expect(toggleFlyoutFullScreenMock).not.toHaveBeenCalled(); - fireEvent.click(renderResult.getByLabelText('fullScreen')); - expect(toggleFlyoutFullScreenMock).toHaveBeenCalled(); - }); }); diff --git a/public/tabs/chat/chat_page.tsx b/public/tabs/chat/chat_page.tsx index 0cb3b803..97da71db 100644 --- a/public/tabs/chat/chat_page.tsx +++ b/public/tabs/chat/chat_page.tsx @@ -56,7 +56,7 @@ export const ChatPage: React.FC = (props) => { - + void; -} - -export const ChatWindowHeader = React.memo((props: ChatWindowHeaderProps) => { +export const ChatWindowHeader = React.memo(() => { const chatContext = useChatContext(); - const dockBottom = () => ( - - - - - - - ); - - const dockRight = () => ( - - - - - - - ); - return ( <> { display={chatContext.selectedTabId === TAB_ID.HISTORY ? 'fill' : undefined} /> - - - +