Skip to content

Commit 01af8e7

Browse files
[chat]Persist chatbot state in local storage (#10895)
* Persist chatbot state in local storage Signed-off-by: Lin Wang <[email protected]> * Changeset file for PR #10895 created/updated --------- Signed-off-by: Lin Wang <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent 55a9ada commit 01af8e7

File tree

8 files changed

+378
-96
lines changed

8 files changed

+378
-96
lines changed

changelogs/fragments/10895.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
feat:
2+
- Persist chatbot state in local storage ([#10895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10895))

src/core/public/overlays/sidecar/sidecar_service.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ export class SidecarService {
209209
private cleanupDom(sidecarConfig$?: BehaviorSubject<ISidecarConfig | undefined>): void {
210210
if (this.targetDomElement != null) {
211211
unmountComponentAtNode(this.targetDomElement);
212-
this.targetDomElement.innerHTML = '';
213212
}
214213
this.activeSidecar = null;
215214
// Reset the sidecar configuration to remove any padding from the main window

src/plugins/chat/public/components/chat_header_button.test.tsx

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('ChatHeaderButton', () => {
2929
let mockCore: ReturnType<typeof coreMock.createStart>;
3030
let mockChatService: jest.Mocked<ChatService>;
3131
let mockContextProvider: any;
32+
let mockSuggestedActionsService: any;
3233

3334
beforeEach(() => {
3435
jest.clearAllMocks();
@@ -38,6 +39,7 @@ describe('ChatHeaderButton', () => {
3839
newThread: jest.fn(),
3940
isWindowOpen: jest.fn().mockReturnValue(false),
4041
getWindowMode: jest.fn().mockReturnValue('sidecar'),
42+
getPaddingSize: jest.fn().mockReturnValue(400),
4143
setWindowState: jest.fn(),
4244
setChatWindowRef: jest.fn(),
4345
clearChatWindowRef: jest.fn(),
@@ -46,6 +48,9 @@ describe('ChatHeaderButton', () => {
4648
onWindowCloseRequest: jest.fn().mockReturnValue(() => {}),
4749
} as any;
4850
mockContextProvider = {};
51+
mockSuggestedActionsService = {
52+
getSuggestedActions: jest.fn().mockReturnValue([]),
53+
};
4954

5055
// Mock sidecar with complete SidecarRef
5156
const mockSidecarRef = {
@@ -65,6 +70,7 @@ describe('ChatHeaderButton', () => {
6570
core={mockCore}
6671
chatService={mockChatService}
6772
contextProvider={mockContextProvider}
73+
suggestedActionsService={mockSuggestedActionsService}
6874
ref={ref}
6975
/>
7076
);
@@ -82,6 +88,7 @@ describe('ChatHeaderButton', () => {
8288
core={mockCore}
8389
chatService={mockChatService}
8490
contextProvider={mockContextProvider}
91+
suggestedActionsService={mockSuggestedActionsService}
8592
ref={ref}
8693
/>
8794
);
@@ -105,6 +112,7 @@ describe('ChatHeaderButton', () => {
105112
core={mockCore}
106113
chatService={mockChatService}
107114
contextProvider={mockContextProvider}
115+
suggestedActionsService={mockSuggestedActionsService}
108116
/>
109117
);
110118

@@ -120,6 +128,7 @@ describe('ChatHeaderButton', () => {
120128
core={mockCore}
121129
chatService={mockChatService}
122130
contextProvider={mockContextProvider}
131+
suggestedActionsService={mockSuggestedActionsService}
123132
/>
124133
);
125134

@@ -132,6 +141,7 @@ describe('ChatHeaderButton', () => {
132141
core={mockCore}
133142
chatService={mockChatService}
134143
contextProvider={mockContextProvider}
144+
suggestedActionsService={mockSuggestedActionsService}
135145
/>
136146
);
137147

@@ -144,6 +154,7 @@ describe('ChatHeaderButton', () => {
144154
core={mockCore}
145155
chatService={mockChatService}
146156
contextProvider={mockContextProvider}
157+
suggestedActionsService={mockSuggestedActionsService}
147158
/>
148159
);
149160

@@ -166,6 +177,7 @@ describe('ChatHeaderButton', () => {
166177
core={mockCore}
167178
chatService={mockChatService}
168179
contextProvider={mockContextProvider}
180+
suggestedActionsService={mockSuggestedActionsService}
169181
/>
170182
);
171183

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

203+
// Start with window open state
204+
mockChatService.isWindowOpen.mockReturnValue(true);
205+
191206
const { container } = render(
192207
<ChatHeaderButton
193208
core={mockCore}
194209
chatService={mockChatService}
195210
contextProvider={mockContextProvider}
211+
suggestedActionsService={mockSuggestedActionsService}
196212
/>
197213
);
198214

199-
// First open the sidecar by clicking the button
200-
const button = container.querySelector('[aria-label="Toggle chat assistant"]') as HTMLElement;
201-
button?.click();
202-
203-
// Wait for the sidecar to be opened
204-
await waitFor(() => {
205-
expect(mockCore.overlays.sidecar.open).toHaveBeenCalled();
206-
});
207-
208-
// Then trigger the close request
215+
// Trigger the close request
209216
closeRequestCallback!();
210217

211-
// Verify close was called
212-
await waitFor(() => {
213-
expect(mockClose).toHaveBeenCalled();
214-
});
218+
// Verify close was called on the sidecar ref
219+
expect(mockChatService.setWindowState).toHaveBeenCalledWith({ isWindowOpen: false });
215220
});
216221

217222
it('should sync local state when ChatService state changes', () => {
218-
let stateChangeCallback: (isOpen: boolean) => void;
223+
let stateChangeCallback: (
224+
newWindowState: any,
225+
changed: { isWindowOpen: boolean; windowMode: boolean; paddingSize: boolean }
226+
) => void;
219227
mockChatService.onWindowStateChange.mockImplementation((cb) => {
220228
stateChangeCallback = cb;
221229
return jest.fn();
@@ -226,11 +234,15 @@ describe('ChatHeaderButton', () => {
226234
core={mockCore}
227235
chatService={mockChatService}
228236
contextProvider={mockContextProvider}
237+
suggestedActionsService={mockSuggestedActionsService}
229238
/>
230239
);
231240

232241
// Trigger state change to open
233-
stateChangeCallback!(true);
242+
stateChangeCallback!(
243+
{ isWindowOpen: true, windowMode: 'sidecar', paddingSize: 400 },
244+
{ isWindowOpen: true, windowMode: false, paddingSize: false }
245+
);
234246

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

@@ -265,6 +278,7 @@ describe('ChatHeaderButton', () => {
265278
core={mockCore}
266279
chatService={mockChatService}
267280
contextProvider={mockContextProvider}
281+
suggestedActionsService={mockSuggestedActionsService}
268282
/>
269283
);
270284

src/plugins/chat/public/components/chat_header_button.tsx

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

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

63-
const sidecarConfig =
64-
layoutMode === ChatLayoutMode.FULLSCREEN
65-
? {
66-
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
67-
paddingSize: window.innerHeight,
68-
isHidden: false,
69-
}
70-
: {
71-
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
72-
paddingSize: 400,
73-
isHidden: false,
74-
};
75-
7663
sideCarRef.current = core.overlays.sidecar.open(flyoutMountPoint.current, {
7764
className: `chat-sidecar chat-sidecar--${layoutMode}`,
78-
config: sidecarConfig,
65+
config: {
66+
dockedMode:
67+
layoutMode === ChatLayoutMode.FULLSCREEN
68+
? SIDECAR_DOCKED_MODE.TAKEOVER
69+
: SIDECAR_DOCKED_MODE.RIGHT,
70+
paddingSize: chatService.getPaddingSize(),
71+
isHidden: false,
72+
},
7973
});
8074

8175
// Notify ChatService that window is now open
82-
chatService.setWindowState(true, layoutMode);
83-
setIsOpen(true);
76+
chatService.setWindowState({ isWindowOpen: true });
8477
}, [core.overlays, layoutMode, chatService]);
8578

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

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

110102
// Update sidecar config dynamically if currently open
111103
if (isOpen && sideCarRef.current) {
112-
const newSidecarConfig =
113-
newLayoutMode === ChatLayoutMode.FULLSCREEN
114-
? {
115-
dockedMode: SIDECAR_DOCKED_MODE.TAKEOVER,
116-
paddingSize: window.innerHeight - 50,
117-
isHidden: false,
118-
}
119-
: {
120-
dockedMode: SIDECAR_DOCKED_MODE.RIGHT,
121-
paddingSize: 400,
122-
isHidden: false,
123-
};
124-
125-
core.overlays.sidecar.setSidecarConfig(newSidecarConfig);
104+
core.overlays.sidecar.setSidecarConfig({
105+
dockedMode:
106+
newLayoutMode === ChatLayoutMode.FULLSCREEN
107+
? SIDECAR_DOCKED_MODE.TAKEOVER
108+
: SIDECAR_DOCKED_MODE.RIGHT,
109+
paddingSize: newLayoutMode === ChatLayoutMode.FULLSCREEN ? window.innerHeight - 50 : 400,
110+
isHidden: false,
111+
});
126112
}
127113

128114
// Update ChatService with new layout mode
129-
chatService.setWindowState(isOpen, newLayoutMode);
115+
chatService.setWindowState({ windowMode: newLayoutMode });
130116
}, [layoutMode, isOpen, chatService, core.overlays.sidecar]);
131117

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

143129
// Listen to ChatService window state changes and sync local state
144130
useEffect(() => {
145-
const unsubscribe = chatService.onWindowStateChange((newIsOpen) => {
146-
setIsOpen(newIsOpen);
147-
});
131+
const unsubscribe = chatService.onWindowStateChange(
132+
({ isWindowOpen, windowMode }, changed) => {
133+
if (changed.isWindowOpen) {
134+
setIsOpen(isWindowOpen);
135+
}
136+
if (changed.windowMode) {
137+
setLayoutMode(windowMode);
138+
}
139+
}
140+
);
148141
return unsubscribe;
149142
}, [chatService]);
150143

@@ -171,11 +164,16 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
171164
// Cleanup on unmount
172165
useUnmount(() => {
173166
if (sideCarRef.current) {
174-
chatService.setWindowState(false);
175167
sideCarRef.current.close();
176168
}
177169
});
178170

171+
useEffectOnce(() => {
172+
if (isOpen) {
173+
openSidecar();
174+
}
175+
});
176+
179177
return (
180178
<>
181179
{/* Text selection monitor - always active when chat UI is rendered */}

0 commit comments

Comments
 (0)