diff --git a/workspaces/lightspeed/.changeset/few-jars-smoke.md b/workspaces/lightspeed/.changeset/few-jars-smoke.md new file mode 100644 index 0000000000..619a29e95c --- /dev/null +++ b/workspaces/lightspeed/.changeset/few-jars-smoke.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': patch +--- + +Added Escape key support to cycle through display modes. Pressing Escape now transitions through Fullscreen → Docked → Overlay → Close. When the Display Mode settings dropdown is open, the first Escape closes the dropdown, and subsequent Escape presses cycle the display mode. diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 9468345efd..5d053e5abb 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -177,6 +177,7 @@ export const LightspeedChat = ({ setCurrentConversationId, draftMessage, setDraftMessage, + setIsSettingsDropdownOpen, } = useLightspeedDrawerContext(); const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; const [isChatHistoryDrawerOpen, setIsChatHistoryDrawerOpen] = @@ -737,6 +738,7 @@ export const LightspeedChat = ({ setDisplayMode={setDisplayMode} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} + setIsSettingsDropdownOpen={setIsSettingsDropdownOpen} /> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index bbfe819630..6b2a9a4267 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -50,6 +50,7 @@ type LightspeedChatBoxHeaderProps = { onPinnedChatsToggle: (state: boolean) => void; isModelSelectorDisabled?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; + setIsSettingsDropdownOpen: (isOpen: boolean) => void; }; const useStyles = makeStyles(theme => @@ -83,8 +84,10 @@ export const LightspeedChatBoxHeader = ({ onPinnedChatsToggle, isModelSelectorDisabled = false, setDisplayMode, + setIsSettingsDropdownOpen, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); + const [isSettingsMenuOpen, setIsSettingsMenuOpen] = useState(false); const { t } = useTranslation(); const styles = useStyles(); @@ -136,6 +139,19 @@ export const LightspeedChatBoxHeader = ({ setDisplayMode(ChatbotDisplayMode.default); }; + // Toggle settings menu (called when clicking the toggle button) + const handleSettingsMenuToggle = () => { + const newState = !isSettingsMenuOpen; + setIsSettingsMenuOpen(newState); + setIsSettingsDropdownOpen(newState); + }; + + // Handle settings menu close (called on Escape or click outside) + const handleSettingsMenuClose = (isOpen: boolean) => { + setIsSettingsMenuOpen(isOpen); + setIsSettingsDropdownOpen(isOpen); + }; + return ( void; + /** + * Whether the settings dropdown is currently open + * Used to prevent Escape key from cycling display modes when dropdown is open + */ + isSettingsDropdownOpen: boolean; + /** + * Setter for settings dropdown open state + */ + setIsSettingsDropdownOpen: (isOpen: boolean) => void; } /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx index 534bfdf8ac..5e26fad67f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx @@ -60,6 +60,8 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { const [draftFileContents, setDraftFileContentsState] = useState< FileContent[] >([]); + const [isSettingsDropdownOpen, setIsSettingsDropdownOpen] = + useState(false); const openedViaFABRef = useRef(false); const isLightspeedRoute = location.pathname.startsWith('/lightspeed'); @@ -188,6 +190,57 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { ], ); + // Cycle display mode on Escape: Fullscreen → Docked → Overlay → Close + const cycleDisplayModeOnEscape = useCallback(() => { + switch (displayModeState) { + case ChatbotDisplayMode.embedded: // Fullscreen → Docked + setDisplayMode(ChatbotDisplayMode.docked); + break; + case ChatbotDisplayMode.docked: // Docked → Overlay + setDisplayMode(ChatbotDisplayMode.default); + break; + case ChatbotDisplayMode.default: // Overlay → Close + closeChatbot(); + break; + default: + break; + } + }, [displayModeState, setDisplayMode, closeChatbot]); + + // Handle ChatbotModal close (overlay mode only) + // Only cycle display mode if no dropdown is open + const handleModalClose = useCallback(() => { + if (isSettingsDropdownOpen) { + // Settings dropdown is open, let it close first + // Don't cycle display mode on this Escape press + return; + } + cycleDisplayModeOnEscape(); + }, [isSettingsDropdownOpen, cycleDisplayModeOnEscape]); + + // Global Escape key listener for display mode cycling + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || !isOpen) { + return; + } + + // Check if any modal dialog is currently visible (Delete, Rename, etc.) + const hasOpenModal = document.querySelector('.MuiDialog-root'); + + // If settings dropdown or modal is open, let those handle Escape first + if (isSettingsDropdownOpen || hasOpenModal) { + return; + } + + event.preventDefault(); + cycleDisplayModeOnEscape(); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, isSettingsDropdownOpen, cycleDisplayModeOnEscape]); + // Only render ChatbotModal for overlay mode // Docked mode is handled by ApplicationDrawer in Root // Embedded mode is handled by LightspeedPage route @@ -210,6 +263,8 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { setDraftMessage, draftFileContents, setDraftFileContents, + isSettingsDropdownOpen, + setIsSettingsDropdownOpen, }), [ isOpen, @@ -224,6 +279,8 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { setDraftMessage, draftFileContents, setDraftFileContents, + isSettingsDropdownOpen, + setIsSettingsDropdownOpen, ], ); @@ -234,7 +291,7 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), }); localStorage.clear(); @@ -508,6 +510,8 @@ describe('LightspeedChat', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), }); render(setupLightspeedChat()); @@ -539,6 +543,8 @@ describe('LightspeedChat', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), }); render(setupLightspeedChat()); @@ -570,6 +576,8 @@ describe('LightspeedChat', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), }); render(setupLightspeedChat()); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx new file mode 100644 index 0000000000..96a60d3a88 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useContext } from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; +import { LightspeedDrawerProvider } from '../LightspeedDrawerProvider'; + +// Mock the LightspeedChatContainer to avoid complex dependencies +jest.mock('../LightspeedChatContainer', () => ({ + LightspeedChatContainer: () => ( +
Chat Container
+ ), +})); + +// Test component to access and display context values +const ContextConsumer = () => { + const context = useContext(LightspeedDrawerContext); + if (!context) return null; + + return ( +
+ {context.displayMode} + + {context.isChatbotActive.toString()} + + + {context.isSettingsDropdownOpen.toString()} + + + + + + + +
+ ); +}; + +const renderWithRouter = (initialEntries: string[] = ['/']) => { + return render( + + + + + , + ); +}; + +describe('LightspeedDrawerProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Escape key display mode cycling', () => { + it('should cycle from fullscreen to docked when Escape is pressed', async () => { + renderWithRouter(['/lightspeed']); + + // Verify we start in fullscreen mode (embedded) on lightspeed route + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.embedded, + ); + + // Press Escape + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Should now be in docked mode + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.docked, + ); + }); + + it('should cycle from docked to overlay when Escape is pressed', async () => { + renderWithRouter(); + + // Open chatbot first + await act(async () => { + fireEvent.click(screen.getByTestId('toggle-chatbot')); + }); + + // Set to docked mode + await act(async () => { + fireEvent.click(screen.getByTestId('set-display-mode-docked')); + }); + + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.docked, + ); + + // Press Escape + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Should now be in overlay mode + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.default, + ); + }); + + it('should close chatbot when Escape is pressed in overlay mode', async () => { + renderWithRouter(); + + // Open chatbot + await act(async () => { + fireEvent.click(screen.getByTestId('toggle-chatbot')); + }); + + expect(screen.getByTestId('is-chatbot-active').textContent).toBe('true'); + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.default, + ); + + // Press Escape + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Chatbot should be closed + expect(screen.getByTestId('is-chatbot-active').textContent).toBe('false'); + }); + + it('should not cycle display mode when settings dropdown is open', async () => { + renderWithRouter(); + + // Open chatbot + await act(async () => { + fireEvent.click(screen.getByTestId('toggle-chatbot')); + }); + + // Set to docked mode + await act(async () => { + fireEvent.click(screen.getByTestId('set-display-mode-docked')); + }); + + // Open settings dropdown + await act(async () => { + fireEvent.click(screen.getByTestId('set-settings-dropdown-open')); + }); + + expect(screen.getByTestId('is-settings-dropdown-open').textContent).toBe( + 'true', + ); + + // Press Escape - should not cycle mode because dropdown is open + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Should still be in docked mode + expect(screen.getByTestId('display-mode').textContent).toBe( + ChatbotDisplayMode.docked, + ); + }); + + it('should not respond to Escape when chatbot is closed', async () => { + renderWithRouter(); + + // Chatbot should be closed initially + expect(screen.getByTestId('is-chatbot-active').textContent).toBe('false'); + + const initialDisplayMode = screen.getByTestId('display-mode').textContent; + + // Press Escape + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + + // Display mode should remain unchanged + expect(screen.getByTestId('display-mode').textContent).toBe( + initialDisplayMode, + ); + }); + }); + + describe('isSettingsDropdownOpen state', () => { + it('should track settings dropdown open state', async () => { + renderWithRouter(); + + // Initially should be false + expect(screen.getByTestId('is-settings-dropdown-open').textContent).toBe( + 'false', + ); + + // Open settings dropdown + await act(async () => { + fireEvent.click(screen.getByTestId('set-settings-dropdown-open')); + }); + + expect(screen.getByTestId('is-settings-dropdown-open').textContent).toBe( + 'true', + ); + + // Close settings dropdown + await act(async () => { + fireEvent.click(screen.getByTestId('set-settings-dropdown-closed')); + }); + + expect(screen.getByTestId('is-settings-dropdown-open').textContent).toBe( + 'false', + ); + }); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx index 08c0bbc182..85d34d8423 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx @@ -41,6 +41,8 @@ describe('LightspeedDrawerStateExposer', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), ...overrides, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx index a59bd76b20..6ab450c81c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx @@ -41,6 +41,8 @@ describe('LightspeedFAB', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), ...overrides, }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx index 2eb718e202..85b524418e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx @@ -34,6 +34,8 @@ describe('useLightspeedDrawerContext', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + isSettingsDropdownOpen: false, + setIsSettingsDropdownOpen: jest.fn(), }; it('should return context value when used within provider', () => { @@ -258,4 +260,41 @@ describe('useLightspeedDrawerContext', () => { result.current.setDraftMessage('new draft message'); expect(mockSetDraftMessage).toHaveBeenCalledWith('new draft message'); }); + + it('should return isSettingsDropdownOpen from context', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + expect(result.current.isSettingsDropdownOpen).toBe(true); + }); + + it('should provide working setIsSettingsDropdownOpen function', () => { + const mockSetIsSettingsDropdownOpen = jest.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLightspeedDrawerContext(), { + wrapper, + }); + + result.current.setIsSettingsDropdownOpen(true); + expect(mockSetIsSettingsDropdownOpen).toHaveBeenCalledWith(true); + }); });