Skip to content

Commit 94c0007

Browse files
[chat] Add ai related actions in Explore (#10834)
* [chat][explore]Add extensible log actions framework with standardized contracts This PR introduces a comprehensive log actions framework that provides a standardized, extensible contract for registering actions on log entries: - Log Actions Contracts: Standardized TypeScript interfaces (LogActionDefinition, LogActionContext, LogActionItemProps) - Log Action Registry: Centralized registry system for managing and discovering compatible actions - LogActionMenu Component: Reusable dropdown interface that dynamically shows available actions for any log entry - Flexible Compatibility System: Actions can define custom logic to determine when they should be available Signed-off-by: Anan Zhuang <[email protected]> * fix PR comments and add tests Signed-off-by: Anan Zhuang <[email protected]> * Changeset file for PR #10834 created/updated --------- Signed-off-by: Anan Zhuang <[email protected]> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com>
1 parent 93949ea commit 94c0007

27 files changed

+2795
-16
lines changed

changelogs/fragments/10834.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
feat:
2+
- [chat] Add ai related actions in Explore ([#10834](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10834))

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

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,23 @@ describe('ChatHeaderButton', () => {
3636
mockChatService = {
3737
sendMessage: jest.fn(),
3838
newThread: jest.fn(),
39+
isWindowOpen: jest.fn().mockReturnValue(false),
40+
getWindowMode: jest.fn().mockReturnValue('sidecar'),
41+
setWindowState: jest.fn(),
42+
setChatWindowRef: jest.fn(),
43+
clearChatWindowRef: jest.fn(),
44+
onWindowStateChange: jest.fn().mockReturnValue(() => {}),
45+
onWindowOpenRequest: jest.fn().mockReturnValue(() => {}),
46+
onWindowCloseRequest: jest.fn().mockReturnValue(() => {}),
3947
} as any;
4048
mockContextProvider = {};
4149

42-
// Mock sidecar
43-
mockCore.overlays.sidecar.open = jest.fn().mockReturnValue({
50+
// Mock sidecar with complete SidecarRef
51+
const mockSidecarRef = {
4452
close: jest.fn(),
45-
});
53+
onClose: Promise.resolve(),
54+
} as any;
55+
mockCore.overlays.sidecar.open = jest.fn().mockReturnValue(mockSidecarRef);
4656
mockCore.overlays.sidecar.setSidecarConfig = jest.fn();
4757
});
4858

@@ -85,4 +95,186 @@ describe('ChatHeaderButton', () => {
8595
});
8696
});
8797
});
98+
99+
describe('initialization', () => {
100+
it('should initialize with window closed state from ChatService', () => {
101+
mockChatService.isWindowOpen.mockReturnValue(false);
102+
103+
render(
104+
<ChatHeaderButton
105+
core={mockCore}
106+
chatService={mockChatService}
107+
contextProvider={mockContextProvider}
108+
/>
109+
);
110+
111+
expect(mockChatService.isWindowOpen).toHaveBeenCalled();
112+
expect(mockChatService.getWindowMode).toHaveBeenCalled();
113+
});
114+
115+
it('should initialize with window open state from ChatService', () => {
116+
mockChatService.isWindowOpen.mockReturnValue(true);
117+
118+
render(
119+
<ChatHeaderButton
120+
core={mockCore}
121+
chatService={mockChatService}
122+
contextProvider={mockContextProvider}
123+
/>
124+
);
125+
126+
expect(mockChatService.isWindowOpen).toHaveBeenCalled();
127+
});
128+
129+
it('should register ChatWindow ref with ChatService', () => {
130+
render(
131+
<ChatHeaderButton
132+
core={mockCore}
133+
chatService={mockChatService}
134+
contextProvider={mockContextProvider}
135+
/>
136+
);
137+
138+
expect(mockChatService.setChatWindowRef).toHaveBeenCalled();
139+
});
140+
141+
it('should subscribe to ChatService state changes', () => {
142+
render(
143+
<ChatHeaderButton
144+
core={mockCore}
145+
chatService={mockChatService}
146+
contextProvider={mockContextProvider}
147+
/>
148+
);
149+
150+
expect(mockChatService.onWindowStateChange).toHaveBeenCalled();
151+
expect(mockChatService.onWindowOpenRequest).toHaveBeenCalled();
152+
expect(mockChatService.onWindowCloseRequest).toHaveBeenCalled();
153+
});
154+
});
155+
156+
describe('window state synchronization', () => {
157+
it('should respond to ChatService window open request', () => {
158+
let openRequestCallback: () => void;
159+
mockChatService.onWindowOpenRequest.mockImplementation((cb) => {
160+
openRequestCallback = cb;
161+
return jest.fn();
162+
});
163+
164+
render(
165+
<ChatHeaderButton
166+
core={mockCore}
167+
chatService={mockChatService}
168+
contextProvider={mockContextProvider}
169+
/>
170+
);
171+
172+
// Trigger the open request
173+
openRequestCallback!();
174+
175+
expect(mockCore.overlays.sidecar.open).toHaveBeenCalled();
176+
});
177+
178+
it('should respond to ChatService window close request when window is open', async () => {
179+
let closeRequestCallback: () => void;
180+
const mockClose = jest.fn();
181+
const mockSidecarRef = {
182+
close: mockClose,
183+
onClose: Promise.resolve(),
184+
} as any;
185+
mockChatService.onWindowCloseRequest.mockImplementation((cb) => {
186+
closeRequestCallback = cb;
187+
return jest.fn();
188+
});
189+
mockCore.overlays.sidecar.open.mockReturnValue(mockSidecarRef);
190+
191+
const { container } = render(
192+
<ChatHeaderButton
193+
core={mockCore}
194+
chatService={mockChatService}
195+
contextProvider={mockContextProvider}
196+
/>
197+
);
198+
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
209+
closeRequestCallback!();
210+
211+
// Verify close was called
212+
await waitFor(() => {
213+
expect(mockClose).toHaveBeenCalled();
214+
});
215+
});
216+
217+
it('should sync local state when ChatService state changes', () => {
218+
let stateChangeCallback: (isOpen: boolean) => void;
219+
mockChatService.onWindowStateChange.mockImplementation((cb) => {
220+
stateChangeCallback = cb;
221+
return jest.fn();
222+
});
223+
224+
render(
225+
<ChatHeaderButton
226+
core={mockCore}
227+
chatService={mockChatService}
228+
contextProvider={mockContextProvider}
229+
/>
230+
);
231+
232+
// Trigger state change to open
233+
stateChangeCallback!(true);
234+
235+
// Verify the component reflects the new state (button color should change)
236+
const button = document.querySelector('[aria-label="Toggle chat assistant"]');
237+
expect(button).toBeTruthy();
238+
});
239+
});
240+
241+
describe('cleanup', () => {
242+
it('should clear ChatWindow ref on unmount', () => {
243+
const { unmount } = render(
244+
<ChatHeaderButton
245+
core={mockCore}
246+
chatService={mockChatService}
247+
contextProvider={mockContextProvider}
248+
/>
249+
);
250+
251+
unmount();
252+
253+
expect(mockChatService.clearChatWindowRef).toHaveBeenCalled();
254+
});
255+
256+
it('should close sidecar on unmount if open', () => {
257+
const mockClose = jest.fn();
258+
mockCore.overlays.sidecar.open.mockReturnValue({
259+
close: mockClose,
260+
onClose: Promise.resolve(),
261+
} as any);
262+
263+
const { unmount } = render(
264+
<ChatHeaderButton
265+
core={mockCore}
266+
chatService={mockChatService}
267+
contextProvider={mockContextProvider}
268+
/>
269+
);
270+
271+
// Open the sidecar
272+
const button = document.querySelector('[aria-label="Toggle chat assistant"]');
273+
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
274+
275+
unmount();
276+
277+
expect(mockClose).toHaveBeenCalled();
278+
});
279+
});
88280
});

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,21 @@ interface ChatHeaderButtonProps {
3232

3333
export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatHeaderButtonProps>(
3434
({ core, chatService, contextProvider, charts }, ref) => {
35-
const [isOpen, setIsOpen] = useState(false);
36-
const [layoutMode, setLayoutMode] = useState<ChatLayoutMode>(ChatLayoutMode.SIDECAR);
35+
// Use ChatService as source of truth for window state
36+
const [isOpen, setIsOpen] = useState<boolean>(chatService.isWindowOpen());
37+
const [layoutMode, setLayoutMode] = useState<ChatLayoutMode>(chatService.getWindowMode());
3738
const sideCarRef = useRef<{ close: () => void }>();
3839
const mountPointRef = useRef<HTMLDivElement>(null);
3940
const chatWindowRef = useRef<ChatWindowInstance>(null);
4041

42+
// Register ChatWindow ref with ChatService for external access
43+
useEffect(() => {
44+
chatService.setChatWindowRef(chatWindowRef);
45+
return () => {
46+
chatService.clearChatWindowRef();
47+
};
48+
}, [chatService]);
49+
4150
const openSidecar = useCallback(() => {
4251
if (!mountPointRef.current) return;
4352

@@ -70,16 +79,20 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
7079
config: sidecarConfig,
7180
});
7281

82+
// Notify ChatService that window is now open
83+
chatService.setWindowState(true, layoutMode);
7384
setIsOpen(true);
74-
}, [core.overlays, layoutMode]);
85+
}, [core.overlays, layoutMode, chatService]);
7586

7687
const closeSidecar = useCallback(() => {
7788
if (sideCarRef.current) {
7889
sideCarRef.current.close();
7990
sideCarRef.current = undefined;
8091
}
92+
// Notify ChatService that window is now closed
93+
chatService.setWindowState(false);
8194
setIsOpen(false);
82-
}, []);
95+
}, [chatService]);
8396

8497
const toggleSidecar = useCallback(() => {
8598
if (isOpen) {
@@ -112,7 +125,10 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
112125

113126
core.overlays.sidecar.setSidecarConfig(newSidecarConfig);
114127
}
115-
}, [layoutMode, isOpen, core.overlays.sidecar]);
128+
129+
// Update ChatService with new layout mode
130+
chatService.setWindowState(isOpen, newLayoutMode);
131+
}, [layoutMode, isOpen, chatService, core.overlays.sidecar]);
116132

117133
const startNewConversation = useCallback<ChatHeaderButtonInstance['startNewConversation']>(
118134
async ({ content }) => {
@@ -125,6 +141,34 @@ export const ChatHeaderButton = React.forwardRef<ChatHeaderButtonInstance, ChatH
125141

126142
useImperativeHandle(ref, () => ({ startNewConversation }), [startNewConversation]);
127143

144+
// Listen to ChatService window state changes and sync local state
145+
useEffect(() => {
146+
const unsubscribe = chatService.onWindowStateChange((newIsOpen) => {
147+
setIsOpen(newIsOpen);
148+
});
149+
return unsubscribe;
150+
}, [chatService]);
151+
152+
// Register callbacks for external window open/close requests
153+
useEffect(() => {
154+
const unsubscribeOpen = chatService.onWindowOpenRequest(() => {
155+
if (!isOpen) {
156+
openSidecar();
157+
}
158+
});
159+
160+
const unsubscribeClose = chatService.onWindowCloseRequest(() => {
161+
if (isOpen) {
162+
closeSidecar();
163+
}
164+
});
165+
166+
return () => {
167+
unsubscribeOpen();
168+
unsubscribeClose();
169+
};
170+
}, [chatService, isOpen, openSidecar, closeSidecar]);
171+
128172
// Cleanup on unmount
129173
useEffect(() => {
130174
return () => {

src/plugins/chat/public/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ export function plugin(initializerContext: PluginInitializerContext) {
1616
return new ChatPlugin(initializerContext);
1717
}
1818
export { ChatPluginSetup, ChatPluginStart } from './types';
19+
export { ChatService } from './services/chat_service';

0 commit comments

Comments
 (0)