Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10895.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Persist chatbot state in local storage ([#10895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10895))
1 change: 0 additions & 1 deletion src/core/public/overlays/sidecar/sidecar_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ export class SidecarService {
private cleanupDom(sidecarConfig$?: BehaviorSubject<ISidecarConfig | undefined>): void {
if (this.targetDomElement != null) {
unmountComponentAtNode(this.targetDomElement);
this.targetDomElement.innerHTML = '';
}
this.activeSidecar = null;
// Reset the sidecar configuration to remove any padding from the main window
Expand Down
46 changes: 30 additions & 16 deletions src/plugins/chat/public/components/chat_header_button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('ChatHeaderButton', () => {
let mockCore: ReturnType<typeof coreMock.createStart>;
let mockChatService: jest.Mocked<ChatService>;
let mockContextProvider: any;
let mockSuggestedActionsService: any;

beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -38,6 +39,7 @@ describe('ChatHeaderButton', () => {
newThread: jest.fn(),
isWindowOpen: jest.fn().mockReturnValue(false),
getWindowMode: jest.fn().mockReturnValue('sidecar'),
getPaddingSize: jest.fn().mockReturnValue(400),
setWindowState: jest.fn(),
setChatWindowRef: jest.fn(),
clearChatWindowRef: jest.fn(),
Expand All @@ -46,6 +48,9 @@ describe('ChatHeaderButton', () => {
onWindowCloseRequest: jest.fn().mockReturnValue(() => {}),
} as any;
mockContextProvider = {};
mockSuggestedActionsService = {
getSuggestedActions: jest.fn().mockReturnValue([]),
};

// Mock sidecar with complete SidecarRef
const mockSidecarRef = {
Expand All @@ -65,6 +70,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
ref={ref}
/>
);
Expand All @@ -82,6 +88,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
ref={ref}
/>
);
Expand All @@ -105,6 +112,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -120,6 +128,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -132,6 +141,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -144,6 +154,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -166,6 +177,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -188,34 +200,30 @@ describe('ChatHeaderButton', () => {
});
mockCore.overlays.sidecar.open.mockReturnValue(mockSidecarRef);

// Start with window open state
mockChatService.isWindowOpen.mockReturnValue(true);

const { container } = render(
<ChatHeaderButton
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

// First open the sidecar by clicking the button
const button = container.querySelector('[aria-label="Toggle chat assistant"]') as HTMLElement;
button?.click();

// Wait for the sidecar to be opened
await waitFor(() => {
expect(mockCore.overlays.sidecar.open).toHaveBeenCalled();
});

// Then trigger the close request
// Trigger the close request
closeRequestCallback!();

// Verify close was called
await waitFor(() => {
expect(mockClose).toHaveBeenCalled();
});
// Verify close was called on the sidecar ref
expect(mockChatService.setWindowState).toHaveBeenCalledWith({ isWindowOpen: false });
});

it('should sync local state when ChatService state changes', () => {
let stateChangeCallback: (isOpen: boolean) => void;
let stateChangeCallback: (
newWindowState: any,
changed: { isWindowOpen: boolean; windowMode: boolean; paddingSize: boolean }
) => void;
mockChatService.onWindowStateChange.mockImplementation((cb) => {
stateChangeCallback = cb;
return jest.fn();
Expand All @@ -226,11 +234,15 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

// Trigger state change to open
stateChangeCallback!(true);
stateChangeCallback!(
{ isWindowOpen: true, windowMode: 'sidecar', paddingSize: 400 },
{ isWindowOpen: true, windowMode: false, paddingSize: false }
);

// Verify the component reflects the new state (button color should change)
const button = document.querySelector('[aria-label="Toggle chat assistant"]');
Expand All @@ -245,6 +257,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand All @@ -265,6 +278,7 @@ describe('ChatHeaderButton', () => {
core={mockCore}
chatService={mockChatService}
contextProvider={mockContextProvider}
suggestedActionsService={mockSuggestedActionsService}
/>
);

Expand Down
74 changes: 36 additions & 38 deletions src/plugins/chat/public/components/chat_header_button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import React, { useCallback, useRef, useState, useEffect, useImperativeHandle } from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { useUnmount } from 'react-use';
import { useEffectOnce, useUnmount } from 'react-use';
import { CoreStart, SIDECAR_DOCKED_MODE } from '../../../../core/public';
import { ChatWindow, ChatWindowInstance } from './chat_window';
import { ChatProvider } from '../contexts/chat_context';
Expand Down Expand Up @@ -60,27 +60,20 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
const openSidecar = useCallback(() => {
if (!flyoutMountPoint.current) return;

const sidecarConfig =
layoutMode === ChatLayoutMode.FULLSCREEN
? {
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
paddingSize: window.innerHeight,
isHidden: false,
}
: {
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
paddingSize: 400,
isHidden: false,
};

sideCarRef.current = core.overlays.sidecar.open(flyoutMountPoint.current, {
className: `chat-sidecar chat-sidecar--${layoutMode}`,
config: sidecarConfig,
config: {
dockedMode:
layoutMode === ChatLayoutMode.FULLSCREEN
? SIDECAR_DOCKED_MODE.TAKEOVER
: SIDECAR_DOCKED_MODE.RIGHT,
paddingSize: chatService.getPaddingSize(),
isHidden: false,
},
});

// Notify ChatService that window is now open
chatService.setWindowState(true, layoutMode);
setIsOpen(true);
chatService.setWindowState({ isWindowOpen: true });
}, [core.overlays, layoutMode, chatService]);

const closeSidecar = useCallback(() => {
Expand All @@ -89,8 +82,7 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
sideCarRef.current = undefined;
}
// Notify ChatService that window is now closed
chatService.setWindowState(false);
setIsOpen(false);
chatService.setWindowState({ isWindowOpen: false });
}, [chatService]);

const toggleSidecar = useCallback(() => {
Expand All @@ -109,24 +101,18 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH

// Update sidecar config dynamically if currently open
if (isOpen && sideCarRef.current) {
const newSidecarConfig =
newLayoutMode === ChatLayoutMode.FULLSCREEN
? {
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
paddingSize: window.innerHeight - 50,
isHidden: false,
}
: {
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
paddingSize: 400,
isHidden: false,
};

core.overlays.sidecar.setSidecarConfig(newSidecarConfig);
core.overlays.sidecar.setSidecarConfig({
dockedMode:
newLayoutMode === ChatLayoutMode.FULLSCREEN
? SIDECAR_DOCKED_MODE.TAKEOVER
: SIDECAR_DOCKED_MODE.RIGHT,
paddingSize: newLayoutMode === ChatLayoutMode.FULLSCREEN ? window.innerHeight - 50 : 400,
isHidden: false,
});
}

// Update ChatService with new layout mode
chatService.setWindowState(isOpen, newLayoutMode);
chatService.setWindowState({ windowMode: newLayoutMode });
}, [layoutMode, isOpen, chatService, core.overlays.sidecar]);

const startNewConversation = useCallback<ChatHeaderButtonInstance['startNewConversation']>(
Expand All @@ -142,9 +128,16 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH

// Listen to ChatService window state changes and sync local state
useEffect(() => {
const unsubscribe = chatService.onWindowStateChange((newIsOpen) => {
setIsOpen(newIsOpen);
});
const unsubscribe = chatService.onWindowStateChange(
({ isWindowOpen, windowMode }, changed) => {
if (changed.isWindowOpen) {
setIsOpen(isWindowOpen);
}
if (changed.windowMode) {
setLayoutMode(windowMode);
}
}
);
return unsubscribe;
}, [chatService]);

Expand All @@ -171,11 +164,16 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
// Cleanup on unmount
useUnmount(() => {
if (sideCarRef.current) {
chatService.setWindowState(false);
sideCarRef.current.close();
}
});

useEffectOnce(() => {
if (isOpen) {
openSidecar();
}
});

return (
<>
{/* Text selection monitor - always active when chat UI is rendered */}
Expand Down
Loading
Loading