diff --git a/workspaces/lightspeed/.changeset/khaki-fans-clap.md b/workspaces/lightspeed/.changeset/khaki-fans-clap.md new file mode 100644 index 00000000000..480c03b9381 --- /dev/null +++ b/workspaces/lightspeed/.changeset/khaki-fans-clap.md @@ -0,0 +1,9 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': patch +--- + +### Lightspeed shell and Notebooks + +- **Fullscreen**: Chat and Notebooks stay on separate tabs. While the Notebooks tab is active, chat-only header actions (for example chat history, pinned chats, and MCP settings) are hidden so the header matches the active surface. +- **Overlay and docked**: Only the **Chat** surface is shown; the Chat/Notebooks tab strip is not shown, because Notebooks is intended for the fullscreen experience only. +- **Leaving fullscreen from Notebooks**: If you switch from fullscreen while on Notebooks to overlay or docked, you land on **Chat** in the shell, and the next time you open fullscreen you start on **Chat** again (no lingering Notebooks selection). diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index ce2ae863817..6b199861b11 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -29,7 +29,7 @@ import { FileRejection, type DropEvent as ReactDropzoneDropEvent, } from 'react-dropzone'; -import { useMatch, useNavigate } from 'react-router-dom'; +import { useLocation, useMatch, useNavigate } from 'react-router-dom'; import { Button, makeStyles } from '@material-ui/core'; import { @@ -166,6 +166,7 @@ const useStyles = makeStyles(theme => ({ }, '& .pf-v6-c-tabs__link, & .pf-v5-c-tabs__link': { backgroundColor: 'transparent', + paddingTop: theme.spacing(2), paddingBottom: theme.spacing(2), fontWeight: 700, cursor: 'pointer', @@ -478,12 +479,41 @@ export const LightspeedChat = ({ const notebooksRouteMatch = useMatch('/lightspeed/notebooks'); const notebookViewRouteMatch = useMatch('/lightspeed/notebooks/:notebookId'); const routeNotebookId = notebookViewRouteMatch?.params?.notebookId; + const { + displayMode, + setDisplayMode, + currentConversationId: routeConversationId, + setCurrentConversationId, + draftMessage, + setDraftMessage, + consumePendingOverlayThreadHandoff, + shellViewTab, + setShellViewTab, + } = useLightspeedDrawerContext(); + const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; + const location = useLocation(); + const isNotebooksFullscreenPath = + location.pathname === '/lightspeed/notebooks' || + location.pathname.startsWith('/lightspeed/notebooks/'); const user = useBackstageUserIdentity(); const [filterValue, setFilterValue] = useState(''); const [announcement, setAnnouncement] = useState(''); - const [activeTab, setActiveTab] = useState( - notebooksRouteMatch || notebookViewRouteMatch ? 1 : 0, - ); + const [activeTab, setActiveTab] = useState(() => { + if (!isFullscreenMode) { + return 0; + } + if (notebooksRouteMatch || notebookViewRouteMatch) { + return 1; + } + const p = location.pathname; + if (p.startsWith('/lightspeed/conversation/')) { + return 0; + } + if (shellViewTab === 1) { + return 1; + } + return 0; + }); const { allowed: hasNotebooksAccess, loading: notebooksPermissionLoading } = useLightspeedNotebooksPermission(); const notebooksPermissionResolved = @@ -547,27 +577,48 @@ export const LightspeedChat = ({ const wasStoppedByUserRef = useRef(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - const { - displayMode, - setDisplayMode, - currentConversationId: routeConversationId, - setCurrentConversationId, - draftMessage, - setDraftMessage, - consumePendingOverlayThreadHandoff, - } = useLightspeedDrawerContext(); - const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; + // Chat vs Notebooks tabs are fullscreen-only; overlay and docked always show Chat. const showChatPanel = !isFullscreenMode || activeTab === 0; const showNotebooksPanel = isFullscreenMode && activeTab !== 0; const [isChatHistoryDrawerOpen, setIsChatHistoryDrawerOpen] = useState(!isMobile && isFullscreenMode); + // Fullscreen: URL drives Chat vs Notebooks, but shellViewTab must win when entering + // fullscreen from overlay/docked on Notebooks while navigation still lands on /lightspeed. + useLayoutEffect(() => { + if (!isFullscreenMode) { + return; + } + if (isNotebooksFullscreenPath) { + setActiveTab(1); + setShellViewTab(1); + return; + } + const isBaseLightspeedChatRoute = + location.pathname === '/lightspeed' || + location.pathname === '/lightspeed/'; + if (shellViewTab === 1 && isBaseLightspeedChatRoute) { + navigate('/lightspeed/notebooks', { replace: true }); + return; + } + setActiveTab(0); + setShellViewTab(0); + }, [ + isFullscreenMode, + isNotebooksFullscreenPath, + shellViewTab, + location.pathname, + navigate, + setShellViewTab, + ]); + const handleNotebookTabSelect = ( _event: React.MouseEvent, tabIndex: number | string, ) => { const nextTab = Number(tabIndex); setActiveTab(nextTab); + setShellViewTab(nextTab); if (nextTab === 1) { navigate('/lightspeed/notebooks'); if (notebooksPermissionResolved) { @@ -582,6 +633,26 @@ export const LightspeedChat = ({ } }; + const setDisplayModeFromHeader = useCallback( + (mode: ChatbotDisplayMode) => { + if (mode !== ChatbotDisplayMode.embedded) { + setDisplayMode(mode); + return; + } + if (activeTab === 1) { + const sid = activeNotebook?.session_id; + setDisplayMode( + mode, + undefined, + sid ? { notebookSessionId: sid } : 'notebooks', + ); + } else { + setDisplayMode(mode); + } + }, + [setDisplayMode, activeTab, activeNotebook?.session_id], + ); + const handleCreateNotebook = useCallback(() => { createNotebookMutation.mutate( { name: UNTITLED_NOTEBOOK_NAME }, @@ -1640,13 +1711,15 @@ export const LightspeedChat = ({ > - + {showChatPanel && ( + + )} {isFullscreenMode && ( @@ -1667,7 +1740,8 @@ export const LightspeedChat = ({ isPinningChatsEnabled={isPinningChatsEnabled} isModelSelectorDisabled={isSendButtonDisabled} hideModelSelector={showNotebooksPanel} - setDisplayMode={setDisplayMode} + showChatTabOptions={!showNotebooksPanel} + setDisplayMode={setDisplayModeFromHeader} displayMode={displayMode} onPinnedChatsToggle={handlePinningChatsToggle} onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} @@ -1793,9 +1867,9 @@ export const LightspeedChat = ({ classes={classes} openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} - onSelectNotebook={(notebook: NotebookSession) => - navigate(`/lightspeed/notebooks/${notebook.session_id}`) - } + onSelectNotebook={(notebook: NotebookSession) => { + navigate(`/lightspeed/notebooks/${notebook.session_id}`); + }} onRename={setRenameNotebookId} onDelete={setDeleteNotebookId} onCreateNotebook={handleCreateNotebook} @@ -1813,7 +1887,10 @@ export const LightspeedChat = ({ variant="outlined" color="primary" style={{ borderRadius: '20px' }} - onClick={() => setActiveTab(0)} + onClick={() => { + setActiveTab(0); + setShellViewTab(0); + }} > {t('permission.notebooks.goBack')} </Button> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 41dac085d43..10196adb361 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -52,6 +52,8 @@ type LightspeedChatBoxHeaderProps = { onMcpSettingsClick: () => void; isModelSelectorDisabled?: boolean; hideModelSelector?: boolean; + /** When false, omits pinned-chats and MCP entries (Chat tab only). */ + showChatTabOptions?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; }; @@ -87,6 +89,7 @@ export const LightspeedChatBoxHeader = ({ onMcpSettingsClick, isModelSelectorDisabled = false, hideModelSelector = false, + showChatTabOptions = true, setDisplayMode, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); @@ -213,40 +216,44 @@ export const LightspeedChatBoxHeader = ({ </DropdownItem> </DropdownList> </DropdownGroup> - <Divider /> - <DropdownGroup> - <DropdownList> - {isPinningChatsEnabled ? ( - <DropdownItem - value="disablePinningChats" - key="disablePinningChat" - icon={<ToggleOnOutlinedIcon sx={{ marginTop: '8px' }} />} - description={t('settings.pinned.enabled.description')} - onClick={() => handlePinningChatsToggle(false)} - > - {t('settings.pinned.disable')} - </DropdownItem> - ) : ( - <DropdownItem - value="enablePinningChats" - key="enablePinningChats" - icon={<ToggleOffOutlinedIcon sx={{ marginTop: '8px' }} />} - description={t('settings.pinned.disabled.description')} - onClick={() => handlePinningChatsToggle(true)} - > - {t('settings.pinned.enable')} - </DropdownItem> - )} - <DropdownItem - value="mcpSettings" - key="mcpSettings" - icon={<McpSettingsIcon />} - onClick={onMcpSettingsClick} - > - {t('settings.mcp.label')} - </DropdownItem> - </DropdownList> - </DropdownGroup> + {showChatTabOptions && ( + <> + <Divider /> + <DropdownGroup> + <DropdownList> + {isPinningChatsEnabled ? ( + <DropdownItem + value="disablePinningChats" + key="disablePinningChat" + icon={<ToggleOnOutlinedIcon sx={{ marginTop: '8px' }} />} + description={t('settings.pinned.enabled.description')} + onClick={() => handlePinningChatsToggle(false)} + > + {t('settings.pinned.disable')} + </DropdownItem> + ) : ( + <DropdownItem + value="enablePinningChats" + key="enablePinningChats" + icon={<ToggleOffOutlinedIcon sx={{ marginTop: '8px' }} />} + description={t('settings.pinned.disabled.description')} + onClick={() => handlePinningChatsToggle(true)} + > + {t('settings.pinned.enable')} + </DropdownItem> + )} + <DropdownItem + value="mcpSettings" + key="mcpSettings" + icon={<McpSettingsIcon />} + onClick={onMcpSettingsClick} + > + {t('settings.mcp.label')} + </DropdownItem> + </DropdownList> + </DropdownGroup> + </> + )} </ChatbotHeaderOptionsDropdown> </ChatbotHeaderActions> ); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx index e90bafe121c..27bbd18e901 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx @@ -20,6 +20,15 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { FileContent } from '../types'; +/** + * When switching to fullscreen (embedded), open the notebooks list or a specific session. + * + * @public + */ +export type LightspeedEmbeddedNotebooksTarget = + | 'notebooks' + | { notebookSessionId: string }; + /** * Type for LightspeedDrawerContext * @@ -39,9 +48,17 @@ export interface LightspeedDrawerContextType { */ displayMode: ChatbotDisplayMode; /** - * Set the display mode (overlay, docked, or fullscreen/embedded) + * Set the display mode (overlay, docked, or fullscreen/embedded). + * When entering embedded mode, optional `embeddedNotebooks` navigates to + * `/lightspeed/notebooks` (or a session URL) instead of the chat route. + * Leaving embedded for overlay or docked resets the shell tab to Chat + * (Notebooks is only available in fullscreen). */ - setDisplayMode: (mode: ChatbotDisplayMode) => void; + setDisplayMode: ( + mode: ChatbotDisplayMode, + conversationIdParam?: string, + embeddedNotebooks?: LightspeedEmbeddedNotebooksTarget, + ) => void; /** * The drawer width (for docked mode) */ @@ -83,6 +100,12 @@ export interface LightspeedDrawerContextType { * lastOpened. Returns true at most once per handoff. */ consumePendingOverlayThreadHandoff?: () => boolean; + /** + * Chat tab is 0, Notebooks tab is 1. Persisted across overlay/docked/fullscreen remounts + * (each display mode mounts its own `LightspeedChat` tree). + */ + shellViewTab: number; + setShellViewTab: (tab: number) => void; } /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx index 9d83f1a46e2..eb0f063bdcb 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -237,6 +237,8 @@ describe('LightspeedChat', () => { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); localStorage.clear(); @@ -619,6 +621,111 @@ describe('LightspeedChat', () => { ); }); + it('should call setDisplayMode with default when leaving fullscreen from notebooks', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.embedded, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 1, + setShellViewTab: jest.fn(), + }); + + render(setupLightspeedChat('/lightspeed/notebooks')); + + await waitFor(() => { + expect(screen.getByText('My Notebooks')).toBeInTheDocument(); + }); + + const settingsButton = screen.getByLabelText('Chatbot options'); + await userEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByText('Overlay')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Overlay')); + + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.default, + ); + }); + + it('should not render Chat/Notebooks tabs in overlay mode', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: true, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.default, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), + }); + + render(setupLightspeedChat()); + + await waitFor(() => { + expect(screen.getByLabelText('Chatbot options')).toBeInTheDocument(); + }); + + expect( + screen.queryByRole('tab', { name: 'Chat' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('tab', { name: 'Notebooks' }), + ).not.toBeInTheDocument(); + }); + + it('should not render Chat/Notebooks tabs in docked mode', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: true, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.docked, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), + }); + + render(setupLightspeedChat()); + + await waitFor(() => { + expect(screen.getByLabelText('Chatbot options')).toBeInTheDocument(); + }); + + expect( + screen.queryByRole('tab', { name: 'Chat' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('tab', { name: 'Notebooks' }), + ).not.toBeInTheDocument(); + }); + it('should show current display mode as selected in full-screen mode', async () => { mockUseLightspeedDrawerContext.mockReturnValue({ isChatbotActive: false, @@ -634,6 +741,8 @@ describe('LightspeedChat', () => { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); render(setupLightspeedChat()); @@ -666,6 +775,8 @@ describe('LightspeedChat', () => { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); render(setupLightspeedChat()); @@ -698,6 +809,8 @@ describe('LightspeedChat', () => { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); render(setupLightspeedChat()); @@ -785,6 +898,9 @@ describe('LightspeedChat', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); }); @@ -797,6 +913,37 @@ describe('LightspeedChat', () => { const chatTab = screen.getByRole('tab', { name: 'Chat' }); expect(chatTab).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByRole('button', { name: 'Chat history menu' }), + ).toBeInTheDocument(); + }); + + it('redirects /lightspeed to /lightspeed/notebooks in fullscreen when shellViewTab is 1', async () => { + mockUseLightspeedDrawerContext.mockReturnValue({ + isChatbotActive: false, + toggleChatbot: jest.fn(), + displayMode: ChatbotDisplayMode.embedded, + setDisplayMode: mockSetDisplayMode, + drawerWidth: 500, + setDrawerWidth: jest.fn(), + currentConversationId: undefined, + setCurrentConversationId: mockSetCurrentConversationId, + draftMessage: '', + setDraftMessage: jest.fn(), + draftFileContents: [], + setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 1, + setShellViewTab: jest.fn(), + }); + + render(setupLightspeedChat('/lightspeed')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/lightspeed/notebooks', { + replace: true, + }); + }); }); it('should initialize to notebooks tab when path is /lightspeed/notebooks', async () => { @@ -808,6 +955,9 @@ describe('LightspeedChat', () => { const notebooksTab = screen.getByRole('tab', { name: 'Notebooks' }); expect(notebooksTab).toHaveAttribute('aria-selected', 'true'); + expect( + screen.queryByRole('button', { name: 'Chat history menu' }), + ).not.toBeInTheDocument(); }); it('should navigate to /lightspeed/notebooks when clicking the Notebooks tab', async () => { @@ -850,6 +1000,9 @@ describe('LightspeedChat', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }); render(setupLightspeedChat('/lightspeed/notebooks')); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx index 5ef255788fd..363edffefb3 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx @@ -97,6 +97,8 @@ function baseContextValue(): LightspeedDrawerContextType { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }; } 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 cd28acf4a99..1e50677f0e7 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(), + shellViewTab: 0, + setShellViewTab: 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 f612ca313fa..bb369788a89 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(), + shellViewTab: 0, + setShellViewTab: 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 2b5e864927c..64b60f9d134 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx @@ -35,6 +35,8 @@ describe('useLightspeedDrawerContext', () => { draftFileContents: [], setDraftFileContents: jest.fn(), consumePendingOverlayThreadHandoff: jest.fn(() => false), + shellViewTab: 0, + setShellViewTab: jest.fn(), }; it('should return context value when used within provider', () => { diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx index 03ea4921148..ee97012213b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx @@ -68,6 +68,7 @@ function HookHarness() { <div data-testid="conversation-id"> {contextValue.currentConversationId ?? 'none'} </div> + <div data-testid="shell-view-tab">{contextValue.shellViewTab}</div> <button type="button" data-testid="toggle-button" @@ -112,6 +113,58 @@ function HookHarness() { > Go lightspeed base </button> + <button + type="button" + data-testid="go-notebooks" + onClick={() => navigate('/lightspeed/notebooks')} + > + Go notebooks + </button> + <button + type="button" + data-testid="set-embedded-notebooks" + onClick={() => + contextValue.setDisplayMode( + ChatbotDisplayMode.embedded, + undefined, + 'notebooks', + ) + } + > + Set embedded notebooks + </button> + <button + type="button" + data-testid="set-embedded-notebook-session" + onClick={() => + contextValue.setDisplayMode(ChatbotDisplayMode.embedded, undefined, { + notebookSessionId: 'sess-1', + }) + } + > + Set embedded notebook session + </button> + <button + type="button" + data-testid="set-shell-notebooks-tab" + onClick={() => contextValue.setShellViewTab(1)} + > + Shell notebooks tab + </button> + <button + type="button" + data-testid="set-shell-chat-tab" + onClick={() => contextValue.setShellViewTab(0)} + > + Shell chat tab + </button> + <button + type="button" + data-testid="set-embedded-plain" + onClick={() => contextValue.setDisplayMode(ChatbotDisplayMode.embedded)} + > + Set embedded plain + </button> </div> ); } @@ -279,6 +332,81 @@ describe('useLightspeedProviderState', () => { ); }); }); + + it('navigates to /lightspeed/notebooks when setDisplayMode(embedded, undefined, notebooks)', async () => { + renderWithRouter(['/catalog']); + + screen.getByTestId('set-embedded-notebooks').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent( + '/lightspeed/notebooks', + ); + expect(screen.getByTestId('display-mode')).toHaveTextContent( + ChatbotDisplayMode.embedded, + ); + }); + }); + + it('navigates to notebook session URL when setDisplayMode passes notebookSessionId', async () => { + renderWithRouter(['/catalog']); + + screen.getByTestId('set-embedded-notebook-session').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent( + '/lightspeed/notebooks/sess-1', + ); + }); + }); + + it('navigates to /lightspeed/notebooks when setDisplayMode(embedded) if shellViewTab is 1', async () => { + renderWithRouter(['/catalog']); + + screen.getByTestId('set-shell-notebooks-tab').click(); + screen.getByTestId('set-embedded-plain').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent( + '/lightspeed/notebooks', + ); + }); + }); + + it('navigates to /lightspeed when setDisplayMode(embedded) if shellViewTab is 0', async () => { + renderWithRouter(['/catalog']); + + screen.getByTestId('set-shell-chat-tab').click(); + screen.getByTestId('set-embedded-plain').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent('/lightspeed'); + }); + }); + + it('resets shellViewTab to Chat when leaving embedded for overlay while on Notebooks', async () => { + renderWithRouter(['/catalog']); + + screen.getByTestId('set-shell-notebooks-tab').click(); + screen.getByTestId('set-embedded-plain').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent( + '/lightspeed/notebooks', + ); + expect(screen.getByTestId('display-mode')).toHaveTextContent( + ChatbotDisplayMode.embedded, + ); + expect(screen.getByTestId('shell-view-tab')).toHaveTextContent('1'); + }); + + screen.getByTestId('set-overlay-mode').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent('/catalog'); + expect(screen.getByTestId('shell-view-tab')).toHaveTextContent('0'); + }); + }); }); describe('/lightspeed route handling', () => { @@ -307,6 +435,40 @@ describe('useLightspeedProviderState', () => { }); }); + it('keeps display mode when navigating between Lightspeed sub-routes (e.g. Chat ↔ Notebooks)', async () => { + displayModeSettingsRef.displayMode = ChatbotDisplayMode.embedded; + + renderWithRouter(['/lightspeed']); + + await waitFor(() => { + expect(screen.getByTestId('display-mode')).toHaveTextContent( + ChatbotDisplayMode.embedded, + ); + }); + + screen.getByTestId('go-notebooks').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent( + '/lightspeed/notebooks', + ); + }); + + expect(screen.getByTestId('display-mode')).toHaveTextContent( + ChatbotDisplayMode.embedded, + ); + + screen.getByTestId('go-lightspeed-base').click(); + + await waitFor(() => { + expect(screen.getByTestId('pathname')).toHaveTextContent('/lightspeed'); + }); + + expect(screen.getByTestId('display-mode')).toHaveTextContent( + ChatbotDisplayMode.embedded, + ); + }); + it('restores persisted docked mode after navigating away from /lightspeed', async () => { displayModeSettingsRef.displayMode = ChatbotDisplayMode.docked; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts index 9a020c91f9d..8c7744db2f7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts @@ -21,7 +21,10 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { useAppDrawer } from '@red-hat-developer-hub/backstage-plugin-app-react'; -import type { LightspeedDrawerContextType } from '../components/LightspeedDrawerContext'; +import type { + LightspeedDrawerContextType, + LightspeedEmbeddedNotebooksTarget, +} from '../components/LightspeedDrawerContext'; import { LIGHTSPEED_APP_DRAWER_ID } from '../const'; import type { FileContent } from '../types'; import { useBackstageUserIdentity } from './useBackstageUserIdentity'; @@ -65,12 +68,22 @@ export function useLightspeedProviderState(): { const [draftFileContents, setDraftFileContentsState] = useState< FileContent[] >([]); + const [shellViewTab, setShellViewTabState] = useState(0); + const shellViewTabRef = useRef(shellViewTab); + shellViewTabRef.current = shellViewTab; + const setShellViewTab = useCallback((tab: number) => { + const next = tab === 1 ? 1 : 0; + shellViewTabRef.current = next; + setShellViewTabState(next); + }, []); const openedViaFABRef = useRef(false); const dockedAfterLeavingFullscreenRef = useRef(false); /** True while navigating off /lightspeed after user chose overlay/docked (URL can lag persisted mode). */ const leavingLightspeedForNonEmbeddedShellRef = useRef(false); /** True until overlay/docked LightspeedChat consumes it (new mount after leaving fullscreen route). */ const pendingOverlayThreadHandoffRef = useRef(false); + /** Used to detect in-app moves (e.g. Chat ↔ Notebooks) so display mode is not reset to embedded. */ + const lightspeedPathnamePrevRef = useRef<string | null>(null); const isLightspeedRouteRef = useRef(false); const persistedDisplayModeRef = useRef(persistedDisplayMode); @@ -117,6 +130,15 @@ export function useLightspeedProviderState(): { }, [navigate]); useEffect(() => { + const pathname = location.pathname; + const prevPathname = lightspeedPathnamePrevRef.current; + const isUnderLightspeedPath = (p: string | null) => + Boolean(p && p.startsWith(LIGHTSPEED_PATH)); + const isInternalLightspeedRouteChange = + isUnderLightspeedPath(prevPathname) && + isUnderLightspeedPath(pathname) && + prevPathname !== pathname; + if (!isLightspeedRoute) { leavingLightspeedForNonEmbeddedShellRef.current = false; } @@ -131,13 +153,15 @@ export function useLightspeedProviderState(): { } if (isLightspeedRoute) { - if ( - leavingLightspeedForNonEmbeddedShellRef.current && - persistedDisplayMode !== ChatbotDisplayMode.embedded - ) { - setDisplayModeState(persistedDisplayMode); - } else { - setDisplayModeState(ChatbotDisplayMode.embedded); + if (!isInternalLightspeedRouteChange) { + if ( + leavingLightspeedForNonEmbeddedShellRef.current && + persistedDisplayMode !== ChatbotDisplayMode.embedded + ) { + setDisplayModeState(persistedDisplayMode); + } else { + setDisplayModeState(ChatbotDisplayMode.embedded); + } } setIsOpen(true); if (!dockedAfterLeavingFullscreenRef.current) { @@ -150,7 +174,15 @@ export function useLightspeedProviderState(): { } else { setDisplayModeState(persistedDisplayMode); } - }, [closeDrawer, conversationId, isLightspeedRoute, persistedDisplayMode]); + + lightspeedPathnamePrevRef.current = pathname; + }, [ + closeDrawer, + conversationId, + isLightspeedRoute, + location.pathname, + persistedDisplayMode, + ]); useEffect(() => { if ( @@ -169,7 +201,11 @@ export function useLightspeedProviderState(): { if (rawMode === ChatbotDisplayMode.embedded) { if (!isLightspeedRoute) { - navigate(lightspeedRoutePath(currentConversationIdState)); + if (shellViewTabRef.current === 1) { + navigate(`${LIGHTSPEED_PATH}/notebooks`); + } else { + navigate(lightspeedRoutePath(currentConversationIdState)); + } } setDisplayModeState(ChatbotDisplayMode.embedded); closeDrawer(LIGHTSPEED_APP_DRAWER_ID); @@ -251,7 +287,11 @@ export function useLightspeedProviderState(): { }, []); const setDisplayMode = useCallback( - (mode: ChatbotDisplayMode, conversationIdParam?: string) => { + ( + mode: ChatbotDisplayMode, + conversationIdParam?: string, + embeddedNotebooks?: LightspeedEmbeddedNotebooksTarget, + ) => { if (mode === displayModeState) { return; } @@ -259,10 +299,23 @@ export function useLightspeedProviderState(): { syncShellDrawerForMode(mode); if (mode === ChatbotDisplayMode.embedded) { - const convId = conversationIdParam ?? currentConversationIdState; - navigate(lightspeedRoutePath(convId)); + if (embeddedNotebooks) { + const path = + embeddedNotebooks === 'notebooks' + ? `${LIGHTSPEED_PATH}/notebooks` + : `${LIGHTSPEED_PATH}/notebooks/${embeddedNotebooks.notebookSessionId}`; + navigate(path); + } else if (shellViewTabRef.current === 1) { + navigate(`${LIGHTSPEED_PATH}/notebooks`); + } else { + const convId = conversationIdParam ?? currentConversationIdState; + navigate(lightspeedRoutePath(convId)); + } setIsOpen(true); } else { + // Notebooks exist only in fullscreen; leaving embedded for overlay/docked + // must not keep shellViewTab on Notebooks (next fullscreen open should be Chat). + setShellViewTab(0); if (isLightspeedRoute) { leavingLightspeedForNonEmbeddedShellRef.current = true; pendingOverlayThreadHandoffRef.current = true; @@ -278,6 +331,7 @@ export function useLightspeedProviderState(): { leaveLightspeedRouteForShellDisplayMode, navigate, setPersistedDisplayMode, + setShellViewTab, syncShellDrawerForMode, ], ); @@ -302,6 +356,8 @@ export function useLightspeedProviderState(): { draftFileContents, setDraftFileContents, consumePendingOverlayThreadHandoff, + shellViewTab, + setShellViewTab, }), [ isOpen, @@ -316,6 +372,8 @@ export function useLightspeedProviderState(): { draftFileContents, setDraftFileContents, consumePendingOverlayThreadHandoff, + shellViewTab, + setShellViewTab, ], );