diff --git a/workspaces/lightspeed/.changeset/bright-waves-flow.md b/workspaces/lightspeed/.changeset/bright-waves-flow.md index 3c737e8b5a..1916a6fab1 100644 --- a/workspaces/lightspeed/.changeset/bright-waves-flow.md +++ b/workspaces/lightspeed/.changeset/bright-waves-flow.md @@ -1,7 +1,14 @@ --- -'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +'@red-hat-developer-hub/backstage-plugin-lightspeed': major --- -Graduate the New Frontend System (NFS) plugin from the `./alpha` export to the primary `./` entry point. Legacy (OFS) component exports are still available from the main path (deprecated, will be removed in a future release) and also accessible at `./legacy`. Translations remain at `./alpha`. +**Breaking:** Legacy (OFS) component exports have been removed from the main `./` entry point and are now exclusively available at the `./legacy` subpath. OFS consumers must update their imports: -Existing OFS dynamic plugin configurations continue to work without changes — `module: Legacy` is advised but not mandatory. +```diff +- import { LightspeedDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; ++ import { LightspeedDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-lightspeed/legacy'; +``` + +**New:** Graduate the New Frontend System (NFS) plugin from `./alpha` to the primary `./` entry point. The drawer state management has been refactored from a React Context (`globalThis` singleton) to a proper global store using `@backstage/version-bridge` + `useSyncExternalStore`, eliminating the Provider dependency for consumers. + +Translations remain at `./alpha`. Existing OFS dynamic plugin configurations using `module: Legacy` continue to work unchanged. diff --git a/workspaces/lightspeed/package.json b/workspaces/lightspeed/package.json index 6d4d67b41d..1993cd0ddf 100644 --- a/workspaces/lightspeed/package.json +++ b/workspaces/lightspeed/package.json @@ -74,6 +74,7 @@ "react": "^18.3.1" }, "resolutions": { + "@backstage/plugin-catalog-node": "2.1.0", "@testing-library/react": "15.0.7", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/workspaces/lightspeed/packages/app-legacy/src/components/Root/Root.tsx b/workspaces/lightspeed/packages/app-legacy/src/components/Root/Root.tsx index 2c9432449f..a9738369c4 100644 --- a/workspaces/lightspeed/packages/app-legacy/src/components/Root/Root.tsx +++ b/workspaces/lightspeed/packages/app-legacy/src/components/Root/Root.tsx @@ -46,7 +46,7 @@ import { LightspeedChatContainer, LightspeedDrawerStateExposer, LightspeedFAB, -} from '@red-hat-developer-hub/backstage-plugin-lightspeed'; +} from '@red-hat-developer-hub/backstage-plugin-lightspeed/legacy'; import { Administration } from '@backstage-community/plugin-rbac'; import { ApplicationDrawer } from './ApplicationDrawer'; import LogoFull from './LogoFull'; diff --git a/workspaces/lightspeed/packages/backend/src/index.ts b/workspaces/lightspeed/packages/backend/src/index.ts index cadcfe2856..f5e895264b 100644 --- a/workspaces/lightspeed/packages/backend/src/index.ts +++ b/workspaces/lightspeed/packages/backend/src/index.ts @@ -48,7 +48,7 @@ backend.add(import('@backstage/plugin-search-backend/alpha')); // search engine // See https://backstage.io/docs/features/search/search-engines -backend.add(import('@backstage/plugin-search-backend-module-pg/alpha')); +// backend.add(import('@backstage/plugin-search-backend-module-pg/alpha')); // search collators backend.add(import('@backstage/plugin-search-backend-module-catalog')); diff --git a/workspaces/lightspeed/plugins/lightspeed/README.md b/workspaces/lightspeed/plugins/lightspeed/README.md index 99d2ee64f1..27edf598ce 100644 --- a/workspaces/lightspeed/plugins/lightspeed/README.md +++ b/workspaces/lightspeed/plugins/lightspeed/README.md @@ -261,7 +261,7 @@ Expected: entries for `LightspeedFABModule` and `LightspeedTranslationsModule`. ### OFS Mode (Scalprum / Legacy) -OFS mode uses Scalprum for dynamic plugin loading. Legacy exports are also available from the default module (PluginRoot), but it is advisable to add `module: Legacy` for clarity. Translation resources require `module: Alpha`: +OFS mode uses Scalprum for dynamic plugin loading. Legacy exports require `module: Legacy`. Translation resources require `module: Alpha`: ```yaml plugins: @@ -335,32 +335,32 @@ import { lightspeedFABModule } from '@red-hat-developer-hub/backstage-plugin-lig ### Legacy / OFS consumers -Legacy component imports from the main package path still work for backwards compatibility, but are deprecated and will be removed in a future release. New OFS consumers should use the `./legacy` subpath: +Legacy component imports have been **removed from the main package path**. OFS consumers must update imports to use the `./legacy` subpath: ```ts -// Still works (deprecated — will be removed in a future release) +// Before (no longer works) import { LightspeedPage, LightspeedDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-lightspeed'; -// Recommended for new OFS consumers +// After (required) import { LightspeedPage, LightspeedDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-lightspeed/legacy'; ``` ### Dynamic plugin configuration (OFS) -Legacy exports are available from the default module (PluginRoot), but it is advisable to add `module: Legacy` for clarity: +Legacy exports are available exclusively from the `Legacy` module. Use `module: Legacy` in your dynamic plugin configuration: ```yaml dynamicRoutes: - path: /intelligent-assistant importName: LightspeedPage - module: Legacy # advisable for clarity, but not mandatory + module: Legacy ``` ### Dynamic plugin module name change The `LightspeedPlugin` scalprum module has been removed. Configurations that previously used `module: LightspeedPlugin` should be updated: -- **OFS/Legacy** — omit `module` (defaults to PluginRoot which has legacy exports) or use `module: Legacy` for clarity +- **OFS/Legacy** — use `module: Legacy` (required since legacy exports are no longer on the default module) - **NFS** — use the default `module: PluginRoot` (or omit `module` entirely) ```yaml @@ -370,9 +370,9 @@ dynamicRoutes: importName: LightspeedPage module: LightspeedPlugin -# After (OFS — either works) +# After (OFS) dynamicRoutes: - path: /intelligent-assistant importName: LightspeedPage - module: Legacy # or omit module entirely + module: Legacy ``` diff --git a/workspaces/lightspeed/plugins/lightspeed/app-config.dynamic.yaml b/workspaces/lightspeed/plugins/lightspeed/app-config.dynamic.yaml index 377e727ba2..ba176ebd18 100644 --- a/workspaces/lightspeed/plugins/lightspeed/app-config.dynamic.yaml +++ b/workspaces/lightspeed/plugins/lightspeed/app-config.dynamic.yaml @@ -15,9 +15,7 @@ dynamicPlugins: # ------------------------------------------------------------ # OFS (Old Frontend System) deployment configuration: - - # Note: Legacy exports are also available from the default module (PluginRoot), - # but it is advisable to add `module: Legacy` for clarity. + # Legacy exports require `module: Legacy` — they are not available on the default module. translationResources: - importName: lightspeedTranslations module: Alpha @@ -25,15 +23,20 @@ dynamicPlugins: dynamicRoutes: - path: /intelligent-assistant importName: LightspeedPage + module: Legacy mountPoints: - mountPoint: application/listener importName: LightspeedFAB + module: Legacy - mountPoint: application/provider importName: LightspeedDrawerProvider + module: Legacy - mountPoint: application/internal/drawer-state importName: LightspeedDrawerStateExposer + module: Legacy - mountPoint: application/internal/drawer-content importName: LightspeedChatContainer + module: Legacy config: id: lightspeed priority: 100 diff --git a/workspaces/lightspeed/plugins/lightspeed/dev/DrawerComponent.tsx b/workspaces/lightspeed/plugins/lightspeed/dev/DrawerComponent.tsx index efbd0f7d18..a59d5c96e0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/dev/DrawerComponent.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/dev/DrawerComponent.tsx @@ -18,12 +18,11 @@ import { PropsWithChildren } from 'react'; import { ChatbotDisplayMode } from '@patternfly/chatbot'; -import { useLightspeedDrawerContext } from '../src/hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../src/hooks/useLightspeedDrawer'; import { CustomDrawer } from './CustomDrawer'; export const DrawerComponent = ({ children }: PropsWithChildren) => { - const { displayMode, drawerWidth, setDrawerWidth } = - useLightspeedDrawerContext(); + const { displayMode, drawerWidth, setDrawerWidth } = useLightspeedDrawer(); return ( ; export default _default; -// @public -export type DrawerState = { - id: string; - isDrawerOpen: boolean; - drawerWidth: number; - setDrawerWidth: (width: number) => void; - closeDrawer: () => void; -}; - -// @public -export type DrawerStateExposerProps = { - onStateChange: (state: DrawerState) => void; -}; - -// @public (undocumented) -export const LightspeedChatContainer: () => JSX_3.Element; - -// @public -export const LightspeedDrawerProvider: React.ComponentType; - -// @public -export const LightspeedDrawerStateExposer: ( - input: DrawerStateExposerProps, -) => null; - -// @public -export const LightspeedFAB: () => JSX_3.Element | null; - // @public (undocumented) export const lightspeedFABModule: FrontendModule; -// @public -export const LightspeedIcon: () => JSX_3.Element; - -// @public -export const LightspeedPage: () => JSX_3.Element; - -// @public -export const lightspeedPlugin: BackstagePlugin< - { - root: RouteRef_2; - lightspeedConversation: SubRouteRef_2< - PathParams<'/conversation/:conversationId'> - >; - lightspeedNotebooks: SubRouteRef_2; - lightspeedNotebookView: SubRouteRef_2>; - }, - {}, - {} ->; - // @public (undocumented) export const lightspeedRedirectModule: FrontendModule; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/AttachmentContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/AttachmentContext.tsx index 48323b09e7..4a81a14c02 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/AttachmentContext.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/AttachmentContext.tsx @@ -25,7 +25,7 @@ import { useState, } from 'react'; -import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../hooks/useLightspeedDrawer'; import { useTranslation } from '../hooks/useTranslation'; import { FileContent } from '../types'; import { isSupportedFileType, readFileAsText } from '../utils/attachment-utils'; @@ -59,8 +59,7 @@ const FileAttachmentContextProvider: FC<{ children: ReactNode; }> = ({ children }) => { const { t } = useTranslation(); - const { draftFileContents, setDraftFileContents } = - useLightspeedDrawerContext(); + const { draftFileContents, setDraftFileContents } = useLightspeedDrawer(); const [currentFileContent, setCurrentFileContent] = useState< FileContent | undefined >(); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 9184eddbe8..67da165467 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -104,7 +104,7 @@ import { } from '../hooks'; import { useCreateNotebook } from '../hooks/notebooks/useCreateNotebook'; import { useNotebookDocuments } from '../hooks/notebooks/useNotebookDocuments'; -import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../hooks/useLightspeedDrawer'; import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; import { useWelcomePrompts } from '../hooks/useWelcomePrompts'; @@ -666,7 +666,7 @@ export const LightspeedChat = ({ consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, - } = useLightspeedDrawerContext(); + } = useLightspeedDrawer(); const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; const location = useLocation(); const isNotebooksFullscreenPath = @@ -972,6 +972,8 @@ export const LightspeedChat = ({ if (lastOpenedId === TEMP_CONVERSATION_ID || lastOpenedId === null) { if (onOverlayLikeSurface && stillOnProvisionalThread) { setNewChatCreated(true); + } else if (!stillOnProvisionalThread) { + setNewChatCreated(false); } } }, [ @@ -1768,6 +1770,7 @@ export const LightspeedChat = ({ onSendMessage={sendMessage} isSendButtonDisabled={isSendButtonDisabled} hasAttachButton + attachButtonPosition="start" handleAttach={handleAttach} hasMicrophoneButton value={draftMessage} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx index 6269c9ff7f..b7e1f0722f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerContext.tsx @@ -14,8 +14,6 @@ * limitations under the License. */ -import React, { createContext } from 'react'; - import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { FileContent } from '../types'; @@ -30,7 +28,7 @@ export type LightspeedEmbeddedNotebooksTarget = | { notebookSessionId: string }; /** - * Type for LightspeedDrawerContext + * Type for Lightspeed drawer state and actions. * * @public */ @@ -66,7 +64,7 @@ export interface LightspeedDrawerContextType { /** * The function for setting the drawer width */ - setDrawerWidth: React.Dispatch>; + setDrawerWidth: (width: number) => void; /** * The current conversation ID */ @@ -107,19 +105,3 @@ export interface LightspeedDrawerContextType { shellViewTab: number; setShellViewTab: (tab: number) => void; } - -const CONTEXT_KEY = '__lightspeed_drawer_context__' as keyof typeof globalThis; - -function getOrCreateContext() { - const existing = (globalThis as any)[CONTEXT_KEY]; - if (existing) - return existing as React.Context; - const ctx = createContext(undefined); - (globalThis as any)[CONTEXT_KEY] = ctx; - return ctx; -} - -/** - * @public - */ -export const LightspeedDrawerContext = getOrCreateContext(); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx index 560c490f54..31c07d2edb 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerProvider.tsx @@ -20,9 +20,9 @@ import { makeStyles } from '@mui/styles'; import { ChatbotModal } from '@patternfly/chatbot'; import { DOCKED_CONTENT_OFFSET } from '../const'; -import { useLightspeedProviderState } from '../hooks/useLightspeedProviderState'; +import { useLightspeedDrawer } from '../hooks/useLightspeedDrawer'; +import { useLightspeedShellState } from '../hooks/useLightspeedProviderState'; import { LightspeedChatContainer } from './LightspeedChatContainer'; -import { LightspeedDrawerContext } from './LightspeedDrawerContext'; const useStyles = makeStyles(theme => ({ chatbotModal: { @@ -40,20 +40,24 @@ const useStyles = makeStyles(theme => ({ })); /** + * Router-bridge shell: syncs React Router / AppDrawer state with the + * global lightspeedDrawerStore and renders the overlay ChatbotModal. + * No Context.Provider — state flows through the singleton store. + * * @public */ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { const classes = useStyles(); - const { contextValue, shouldRenderOverlayModal, closeChatbot } = - useLightspeedProviderState(); + const { shouldRenderOverlayModal, closeChatbot } = useLightspeedShellState(); + const { displayMode } = useLightspeedDrawer(); return ( - + <> {children} {shouldRenderOverlayModal && ( closeChatbot()} ouiaId="LightspeedChatbotModal" @@ -63,6 +67,6 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { )} - + ); }; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx index a41cc30d88..765d6b7f68 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx @@ -18,7 +18,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { ChatbotDisplayMode } from '@patternfly/chatbot'; -import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../hooks/useLightspeedDrawer'; /** * @public @@ -60,7 +60,7 @@ export const LightspeedDrawerStateExposer = ({ setDrawerWidth, toggleChatbot, isChatbotActive, - } = useLightspeedDrawerContext(); + } = useLightspeedDrawer(); const isDrawerOpen = displayMode === ChatbotDisplayMode.docked && isChatbotActive; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFABContent.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFABContent.tsx index cf38a37e40..e57510081e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFABContent.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFABContent.tsx @@ -20,14 +20,13 @@ import Tooltip from '@mui/material/Tooltip'; import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { DOCKED_CONTENT_OFFSET } from '../const'; -import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../hooks/useLightspeedDrawer'; import { useTranslation } from '../hooks/useTranslation'; import { LightspeedFABIcon, LightspeedFABOpenIcon } from './LightspeedIcon'; export const LightspeedFABContent = () => { const { t } = useTranslation(); - const { isChatbotActive, toggleChatbot, displayMode } = - useLightspeedDrawerContext(); + const { isChatbotActive, toggleChatbot, displayMode } = useLightspeedDrawer(); if (displayMode === ChatbotDisplayMode.embedded) { return null; 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 78e3d9b648..3cb0fa3e00 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -38,7 +38,7 @@ import userEvent from '@testing-library/user-event'; import { lightspeedApiRef } from '../../api/api'; import { notebooksApiRef } from '../../api/notebooksApi'; import { useConversations, useNotebookSessions } from '../../hooks'; -import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext'; +import { useLightspeedDrawer } from '../../hooks/useLightspeedDrawer'; import { mockUseTranslation } from '../../test-utils/mockTranslations'; import FileAttachmentContextProvider from '../AttachmentContext'; import { LightspeedChat } from '../LightSpeedChat'; @@ -109,8 +109,8 @@ jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); -jest.mock('../../hooks/useLightspeedDrawerContext', () => ({ - useLightspeedDrawerContext: jest.fn(), +jest.mock('../../hooks/useLightspeedDrawer', () => ({ + useLightspeedDrawer: jest.fn(), })); jest.mock('../../hooks/usePinnedChatsSettings', () => ({ usePinnedChatsSettings: jest.fn().mockReturnValue({ @@ -150,10 +150,9 @@ const mockUseNotebookSessions = useNotebookSessions as jest.Mock; const mockUsePermission = usePermission as jest.MockedFunction< typeof usePermission >; -const mockUseLightspeedDrawerContext = - useLightspeedDrawerContext as jest.MockedFunction< - typeof useLightspeedDrawerContext - >; +const mockUseLightspeedDrawer = useLightspeedDrawer as jest.MockedFunction< + typeof useLightspeedDrawer +>; const configAPi = mockApis.config({ data: { @@ -242,7 +241,7 @@ describe('LightspeedChat', () => { data: [], refetch: jest.fn(), }); - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -661,7 +660,7 @@ describe('LightspeedChat', () => { }); it('should call setDisplayMode with default when leaving fullscreen from notebooks', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -702,7 +701,7 @@ describe('LightspeedChat', () => { }); it('should not render Chat/Notebooks tabs in overlay mode', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: true, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.default, @@ -735,7 +734,7 @@ describe('LightspeedChat', () => { }); it('should not render Chat/Notebooks tabs in docked mode', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: true, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.docked, @@ -768,7 +767,7 @@ describe('LightspeedChat', () => { }); it('should show current display mode as selected in full-screen mode', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -802,7 +801,7 @@ describe('LightspeedChat', () => { }); it('should show current display mode as selected in docked mode', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: true, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.docked, @@ -836,7 +835,7 @@ describe('LightspeedChat', () => { }); it('should show current display mode as selected in overlay mode', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.default, @@ -930,7 +929,7 @@ describe('LightspeedChat', () => { describe('notebook tab routing', () => { beforeEach(() => { mockUsePermission.mockReturnValue({ loading: false, allowed: true }); - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -966,7 +965,7 @@ describe('LightspeedChat', () => { }); it('redirects /intelligent-assistant to /intelligent-assistant/notebooks in fullscreen when shellViewTab is 1', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -1041,7 +1040,7 @@ describe('LightspeedChat', () => { }); it('should navigate to conversation URL when switching to chat tab with an active conversation', async () => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, @@ -1076,7 +1075,7 @@ describe('LightspeedChat', () => { describe('fullscreen mode specific features', () => { beforeEach(() => { - mockUseLightspeedDrawerContext.mockReturnValue({ + mockUseLightspeedDrawer.mockReturnValue({ isChatbotActive: false, toggleChatbot: jest.fn(), displayMode: ChatbotDisplayMode.embedded, 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 363edffefb..355687b353 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerProvider.test.tsx @@ -18,16 +18,23 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext'; -import type { LightspeedDrawerContextType } from '../LightspeedDrawerContext'; +import { useLightspeedDrawer } from '../../hooks/useLightspeedDrawer'; import { LightspeedDrawerProvider } from '../LightspeedDrawerProvider'; -const mockUseLightspeedProviderState = jest.fn(); +const mockUseLightspeedShellState = jest.fn(); jest.mock('../../hooks/useLightspeedProviderState', () => ({ - useLightspeedProviderState: () => mockUseLightspeedProviderState(), + useLightspeedShellState: () => mockUseLightspeedShellState(), })); +jest.mock('../../hooks/useLightspeedDrawer', () => ({ + useLightspeedDrawer: jest.fn(), +})); + +const mockUseLightspeedDrawer = useLightspeedDrawer as jest.MockedFunction< + typeof useLightspeedDrawer +>; + jest.mock('@patternfly/chatbot', () => { const actual = jest.requireActual('@patternfly/chatbot'); return { @@ -82,7 +89,7 @@ jest.mock('../LightspeedChatContainer', () => ({ ), })); -function baseContextValue(): LightspeedDrawerContextType { +function baseMockDrawerReturn() { return { isChatbotActive: false, toggleChatbot: jest.fn(), @@ -103,19 +110,14 @@ function baseContextValue(): LightspeedDrawerContextType { } describe('LightspeedDrawerProvider', () => { - const ContextReader = () => { - const ctx = useLightspeedDrawerContext(); - return {ctx.displayMode}; - }; - beforeEach(() => { jest.clearAllMocks(); const closeChatbot = jest.fn(); - mockUseLightspeedProviderState.mockReturnValue({ - contextValue: baseContextValue(), + mockUseLightspeedShellState.mockReturnValue({ shouldRenderOverlayModal: false, closeChatbot, }); + mockUseLightspeedDrawer.mockReturnValue(baseMockDrawerReturn()); }); it('renders children', () => { @@ -127,27 +129,6 @@ describe('LightspeedDrawerProvider', () => { expect(screen.getByText('child content')).toBeInTheDocument(); }); - it('exposes hook contextValue to consumers', () => { - mockUseLightspeedProviderState.mockReturnValue({ - contextValue: { - ...baseContextValue(), - displayMode: ChatbotDisplayMode.docked, - }, - shouldRenderOverlayModal: false, - closeChatbot: jest.fn(), - }); - - render( - - - , - ); - - expect(screen.getByTestId('context-display-mode')).toHaveTextContent( - ChatbotDisplayMode.docked, - ); - }); - it('does not render overlay ChatbotModal when shouldRenderOverlayModal is false', () => { render( @@ -159,14 +140,14 @@ describe('LightspeedDrawerProvider', () => { it('renders ChatbotModal with LightspeedChatContainer when shouldRenderOverlayModal is true', () => { const closeChatbot = jest.fn(); - mockUseLightspeedProviderState.mockReturnValue({ - contextValue: { - ...baseContextValue(), - displayMode: ChatbotDisplayMode.default, - }, + mockUseLightspeedShellState.mockReturnValue({ shouldRenderOverlayModal: true, closeChatbot, }); + mockUseLightspeedDrawer.mockReturnValue({ + ...baseMockDrawerReturn(), + displayMode: ChatbotDisplayMode.default, + }); render( @@ -191,8 +172,7 @@ describe('LightspeedDrawerProvider', () => { it('wires ChatbotModal onEscapePress to closeChatbot from the hook', async () => { const user = userEvent.setup(); const closeChatbot = jest.fn(); - mockUseLightspeedProviderState.mockReturnValue({ - contextValue: baseContextValue(), + mockUseLightspeedShellState.mockReturnValue({ shouldRenderOverlayModal: true, closeChatbot, }); 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 1e50677f0e..65c2b41de9 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx @@ -17,18 +17,26 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { render, waitFor } from '@testing-library/react'; -import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; +import { useLightspeedDrawer } from '../../hooks/useLightspeedDrawer'; import { DrawerState, LightspeedDrawerStateExposer, } from '../LightspeedDrawerStateExposer'; +jest.mock('../../hooks/useLightspeedDrawer', () => ({ + useLightspeedDrawer: jest.fn(), +})); + +const mockUseLightspeedDrawer = useLightspeedDrawer as jest.MockedFunction< + typeof useLightspeedDrawer +>; + describe('LightspeedDrawerStateExposer', () => { const mockSetDrawerWidth = jest.fn(); const mockOnStateChange = jest.fn(); const mockToggleChatbot = jest.fn(); - const createContextValue = (overrides = {}) => ({ + const createMockReturn = (overrides = {}) => ({ isChatbotActive: false, toggleChatbot: mockToggleChatbot, displayMode: ChatbotDisplayMode.default, @@ -41,27 +49,19 @@ describe('LightspeedDrawerStateExposer', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), ...overrides, }); - const renderWithContext = ( - contextValue: ReturnType, - ) => { - return render( - - - , - ); - }; - beforeEach(() => { jest.clearAllMocks(); + mockUseLightspeedDrawer.mockReturnValue(createMockReturn()); }); it('should call onStateChange with initial state', async () => { - renderWithContext(createContextValue()); + render(); await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith({ @@ -75,13 +75,15 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should set isDrawerOpen to true when displayMode is docked AND chatbot is active', async () => { - renderWithContext( - createContextValue({ + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.docked, isChatbotActive: true, }), ); + render(); + await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -92,13 +94,15 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should set isDrawerOpen to false when displayMode is docked but chatbot is not active', async () => { - renderWithContext( - createContextValue({ + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.docked, isChatbotActive: false, }), ); + render(); + await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -109,12 +113,12 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should set isDrawerOpen to false when displayMode is overlay', async () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.default, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.default }), ); + render(); + await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -125,12 +129,12 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should set isDrawerOpen to false when displayMode is fullscreen', async () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.fullscreen, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.fullscreen }), ); + render(); + await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith( expect.objectContaining({ @@ -141,94 +145,30 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should include the correct drawerWidth in state', async () => { - renderWithContext( - createContextValue({ - drawerWidth: 600, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ drawerWidth: 600 }), ); - await waitFor(() => { - expect(mockOnStateChange).toHaveBeenCalledWith( - expect.objectContaining({ - drawerWidth: 600, - }), - ); - }); - }); - - it('should call onStateChange when drawerWidth changes', async () => { - const { rerender } = renderWithContext( - createContextValue({ - drawerWidth: 500, - }), - ); - - await waitFor(() => { - expect(mockOnStateChange).toHaveBeenCalledTimes(1); - }); - - rerender( - - - , - ); - - await waitFor(() => { - expect(mockOnStateChange).toHaveBeenCalledWith( - expect.objectContaining({ - drawerWidth: 700, - }), - ); - }); - }); - - it('should call onStateChange when displayMode changes', async () => { - const { rerender } = renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.default, - isChatbotActive: false, - }), - ); + render(); await waitFor(() => { expect(mockOnStateChange).toHaveBeenCalledWith( expect.objectContaining({ - isDrawerOpen: false, - }), - ); - }); - - rerender( - - - , - ); - - await waitFor(() => { - expect(mockOnStateChange).toHaveBeenCalledWith( - expect.objectContaining({ - isDrawerOpen: true, + drawerWidth: 600, }), ); }); }); it('should render null', () => { - const { container } = renderWithContext(createContextValue()); + const { container } = render( + , + ); expect(container.firstChild).toBeNull(); }); it('should always use lightspeed as the id', async () => { - renderWithContext(createContextValue()); + render(); await waitFor(() => { const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState; @@ -237,7 +177,7 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should pass setDrawerWidth function in state', async () => { - renderWithContext(createContextValue()); + render(); await waitFor(() => { const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState; @@ -246,7 +186,7 @@ describe('LightspeedDrawerStateExposer', () => { }); it('should call toggleChatbot when closeDrawer is invoked', async () => { - renderWithContext(createContextValue()); + render(); await waitFor(() => { const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState; 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 67af1716dd..a3a6f89ab5 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx @@ -17,18 +17,26 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { fireEvent, render, screen } from '@testing-library/react'; +import { useLightspeedDrawer } from '../../hooks/useLightspeedDrawer'; import { mockUseTranslation } from '../../test-utils/mockTranslations'; -import { LightspeedDrawerContext } from '../LightspeedDrawerContext'; import { LightspeedFAB } from '../LightspeedFAB'; jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); +jest.mock('../../hooks/useLightspeedDrawer', () => ({ + useLightspeedDrawer: jest.fn(), +})); + +const mockUseLightspeedDrawer = useLightspeedDrawer as jest.MockedFunction< + typeof useLightspeedDrawer +>; + describe('LightspeedFAB', () => { const mockToggleChatbot = jest.fn(); - const createContextValue = (overrides = {}) => ({ + const createMockReturn = (overrides = {}) => ({ isChatbotActive: false, toggleChatbot: mockToggleChatbot, displayMode: ChatbotDisplayMode.default, @@ -41,31 +49,22 @@ describe('LightspeedFAB', () => { setDraftMessage: jest.fn(), draftFileContents: [], setDraftFileContents: jest.fn(), + consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), ...overrides, }); - const renderWithContext = ( - contextValue: ReturnType, - ) => { - return render( - - - , - ); - }; - beforeEach(() => { jest.clearAllMocks(); + mockUseLightspeedDrawer.mockReturnValue(createMockReturn()); }); it('should render FAB button when displayMode is overlay', () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.default, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.default }), ); + render(); expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); expect( @@ -74,11 +73,10 @@ describe('LightspeedFAB', () => { }); it('should render FAB button when displayMode is docked', () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.docked, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.docked }), ); + render(); expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); expect( @@ -87,21 +85,19 @@ describe('LightspeedFAB', () => { }); it('should not render FAB button when displayMode is embedded', () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.embedded, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.embedded }), ); + render(); expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument(); }); it('should call toggleChatbot when FAB button is clicked', () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.default, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.default }), ); + render(); const fabButton = screen.getByLabelText('Open intelligent assistant'); fireEvent.click(fabButton); @@ -110,33 +106,34 @@ describe('LightspeedFAB', () => { }); it('should show chevron-down icon when chatbot is active', () => { - renderWithContext( - createContextValue({ + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ isChatbotActive: true, displayMode: ChatbotDisplayMode.default, }), ); + render(); expect(screen.getByTestId('lightspeed-fab-open-icon')).toBeInTheDocument(); }); it('should show LightspeedFABIcon when chatbot is not active', () => { - renderWithContext( - createContextValue({ + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ isChatbotActive: false, displayMode: ChatbotDisplayMode.default, }), ); + render(); expect(screen.getByTestId('lightspeed-fab-icon')).toBeInTheDocument(); }); it('should not render when displayMode is fullscreen', () => { - renderWithContext( - createContextValue({ - displayMode: ChatbotDisplayMode.fullscreen, - }), + mockUseLightspeedDrawer.mockReturnValue( + createMockReturn({ displayMode: ChatbotDisplayMode.fullscreen }), ); + render(); expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument(); }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedPage.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedPage.test.tsx index 1f694d88c9..5dbbb3aa52 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedPage.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedPage.test.tsx @@ -34,8 +34,8 @@ jest.mock('@backstage/plugin-permission-react', () => ({ RequirePermission: jest.fn(), })); -jest.mock('../../hooks/useLightspeedDrawerContext', () => ({ - useLightspeedDrawerContext: () => ({ +jest.mock('../../hooks/useLightspeedDrawer', () => ({ + useLightspeedDrawer: () => ({ draftFileContents: [], setDraftFileContents: jest.fn(), }), diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawer.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawer.test.tsx new file mode 100644 index 0000000000..2bbd3c5707 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawer.test.tsx @@ -0,0 +1,185 @@ +/* + * 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 { ChatbotDisplayMode } from '@patternfly/chatbot'; +import { act, renderHook } from '@testing-library/react'; + +import { lightspeedDrawerStore } from '../../store/lightspeedDrawerStore'; +import { useLightspeedDrawer } from '../useLightspeedDrawer'; + +describe('useLightspeedDrawer', () => { + beforeEach(() => { + lightspeedDrawerStore.reset(); + }); + + it('returns initial state without a Provider', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + expect(result.current.isChatbotActive).toBe(false); + expect(result.current.displayMode).toBe(ChatbotDisplayMode.default); + expect(result.current.drawerWidth).toBe(400); + expect(result.current.currentConversationId).toBeUndefined(); + expect(result.current.draftMessage).toBe(''); + expect(result.current.draftFileContents).toEqual([]); + expect(result.current.shellViewTab).toBe(0); + }); + + it('reacts to store.open()', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + lightspeedDrawerStore.open(); + }); + + expect(result.current.isChatbotActive).toBe(true); + }); + + it('reacts to store.close()', () => { + lightspeedDrawerStore.open(); + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + lightspeedDrawerStore.close(); + }); + + expect(result.current.isChatbotActive).toBe(false); + }); + + it('toggle works without registered handlers', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.toggleChatbot(); + }); + expect(result.current.isChatbotActive).toBe(true); + + act(() => { + result.current.toggleChatbot(); + }); + expect(result.current.isChatbotActive).toBe(false); + }); + + it('setDisplayMode updates displayMode without handlers', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.setDisplayMode(ChatbotDisplayMode.docked); + }); + + expect(result.current.displayMode).toBe(ChatbotDisplayMode.docked); + }); + + it('setDrawerWidth updates drawerWidth', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.setDrawerWidth(600); + }); + + expect(result.current.drawerWidth).toBe(600); + }); + + it('setCurrentConversationId updates the conversation id', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.setCurrentConversationId('conv-123'); + }); + + expect(result.current.currentConversationId).toBe('conv-123'); + }); + + it('setDraftMessage updates draftMessage', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.setDraftMessage('hello world'); + }); + + expect(result.current.draftMessage).toBe('hello world'); + }); + + it('setDraftFileContents updates draftFileContents', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + const files = [{ content: 'test', type: 'text/plain', name: 'test.txt' }]; + + act(() => { + result.current.setDraftFileContents(files); + }); + + expect(result.current.draftFileContents).toEqual(files); + }); + + it('setShellViewTab clamps to 0 or 1', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.setShellViewTab(1); + }); + expect(result.current.shellViewTab).toBe(1); + + act(() => { + result.current.setShellViewTab(5); + }); + expect(result.current.shellViewTab).toBe(0); + }); + + it('consumePendingOverlayThreadHandoff returns false when not pending', () => { + const { result } = renderHook(() => useLightspeedDrawer()); + expect(result.current.consumePendingOverlayThreadHandoff?.()).toBe(false); + }); + + it('consumePendingOverlayThreadHandoff returns true once when pending', () => { + lightspeedDrawerStore.setPendingOverlayThreadHandoff(true); + const { result } = renderHook(() => useLightspeedDrawer()); + + expect(result.current.consumePendingOverlayThreadHandoff?.()).toBe(true); + expect(result.current.consumePendingOverlayThreadHandoff?.()).toBe(false); + }); + + it('delegates to registered handlers when available', () => { + const mockToggle = jest.fn(); + const mockSetDisplayMode = jest.fn(); + const mockSetConversationId = jest.fn(); + + lightspeedDrawerStore.registerHandlers({ + toggleChatbot: mockToggle, + setDisplayMode: mockSetDisplayMode, + setCurrentConversationId: mockSetConversationId, + }); + + const { result } = renderHook(() => useLightspeedDrawer()); + + act(() => { + result.current.toggleChatbot(); + }); + expect(mockToggle).toHaveBeenCalledTimes(1); + + act(() => { + result.current.setDisplayMode(ChatbotDisplayMode.embedded); + }); + expect(mockSetDisplayMode).toHaveBeenCalledWith( + ChatbotDisplayMode.embedded, + undefined, + undefined, + ); + + act(() => { + result.current.setCurrentConversationId('abc'); + }); + expect(mockSetConversationId).toHaveBeenCalledWith('abc'); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx deleted file mode 100644 index 64b60f9d13..0000000000 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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 { ChatbotDisplayMode } from '@patternfly/chatbot'; -import { renderHook } from '@testing-library/react'; - -import { LightspeedDrawerContext } from '../../components/LightspeedDrawerContext'; -import { useLightspeedDrawerContext } from '../useLightspeedDrawerContext'; - -describe('useLightspeedDrawerContext', () => { - const mockContextValue = { - isChatbotActive: true, - toggleChatbot: jest.fn(), - displayMode: ChatbotDisplayMode.default, - setDisplayMode: jest.fn(), - drawerWidth: 500, - setDrawerWidth: jest.fn(), - currentConversationId: 'test-conv-id', - setCurrentConversationId: jest.fn(), - draftMessage: '', - setDraftMessage: jest.fn(), - draftFileContents: [], - setDraftFileContents: jest.fn(), - consumePendingOverlayThreadHandoff: jest.fn(() => false), - shellViewTab: 0, - setShellViewTab: jest.fn(), - }; - - it('should return context value when used within provider', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current).toEqual(mockContextValue); - }); - - it('should throw error when used outside of provider', () => { - // Suppress console.error for this test as React will log the error - const consoleSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - - expect(() => { - renderHook(() => useLightspeedDrawerContext()); - }).toThrow( - 'useLightspeedDrawerContext must be used within a LightspeedDrawerProvider', - ); - - consoleSpy.mockRestore(); - }); - - it('should return isChatbotActive from context', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.isChatbotActive).toBe(false); - }); - - it('should return displayMode from context', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.displayMode).toBe(ChatbotDisplayMode.docked); - }); - - it('should return drawerWidth from context', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.drawerWidth).toBe(600); - }); - - it('should return currentConversationId from context', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.currentConversationId).toBe('my-conv-123'); - }); - - it('should return undefined currentConversationId when not set', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.currentConversationId).toBeUndefined(); - }); - - it('should provide working toggleChatbot function', () => { - const mockToggle = jest.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - result.current.toggleChatbot(); - expect(mockToggle).toHaveBeenCalledTimes(1); - }); - - it('should provide working setDisplayMode function', () => { - const mockSetDisplayMode = jest.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - result.current.setDisplayMode(ChatbotDisplayMode.fullscreen); - expect(mockSetDisplayMode).toHaveBeenCalledWith( - ChatbotDisplayMode.fullscreen, - ); - }); - - it('should provide working setDrawerWidth function', () => { - const mockSetDrawerWidth = jest.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - result.current.setDrawerWidth(800); - expect(mockSetDrawerWidth).toHaveBeenCalledWith(800); - }); - - it('should provide working setCurrentConversationId function', () => { - const mockSetConversationId = jest.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - result.current.setCurrentConversationId('new-conv-id'); - expect(mockSetConversationId).toHaveBeenCalledWith('new-conv-id'); - }); - - it('should return draftMessage from context', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - expect(result.current.draftMessage).toBe('test draft message'); - }); - - it('should provide working setDraftMessage function', () => { - const mockSetDraftMessage = jest.fn(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - - const { result } = renderHook(() => useLightspeedDrawerContext(), { - wrapper, - }); - - result.current.setDraftMessage('new draft message'); - expect(mockSetDraftMessage).toHaveBeenCalledWith('new draft message'); - }); -}); 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 065afb8762..6f497c0767 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedProviderState.test.tsx @@ -20,9 +20,11 @@ import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { render, screen, waitFor } from '@testing-library/react'; import { LIGHTSPEED_APP_DRAWER_ID } from '../../const'; +import { lightspeedDrawerStore } from '../../store/lightspeedDrawerStore'; import { useBackstageUserIdentity } from '../useBackstageUserIdentity'; import { useDisplayModeSettings } from '../useDisplayModeSettings'; -import { useLightspeedProviderState } from '../useLightspeedProviderState'; +import { useLightspeedDrawer } from '../useLightspeedDrawer'; +import { useLightspeedShellState } from '../useLightspeedProviderState'; const mockOpenDrawer = jest.fn(); const mockCloseDrawer = jest.fn(); @@ -52,8 +54,8 @@ const mockUser = 'user:default/test'; function HookHarness() { const navigate = useNavigate(); const location = useLocation(); - const { contextValue, shouldRenderOverlayModal } = - useLightspeedProviderState(); + const { shouldRenderOverlayModal } = useLightspeedShellState(); + const contextValue = useLightspeedDrawer(); return (
@@ -182,6 +184,7 @@ describe('useLightspeedProviderState', () => { jest.clearAllMocks(); mockOpenDrawer.mockClear(); mockCloseDrawer.mockClear(); + lightspeedDrawerStore.reset(); displayModeSettingsRef.displayMode = ChatbotDisplayMode.default; jest.mocked(useDisplayModeSettings).mockImplementation(() => ({ diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawer.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawer.ts new file mode 100644 index 0000000000..d9ea2df4d6 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawer.ts @@ -0,0 +1,73 @@ +/* + * 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 { useSyncExternalStore } from 'react'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import type { LightspeedDrawerContextType } from '../components/LightspeedDrawerContext'; +import { + DEFAULT_DRAWER_WIDTH, + lightspeedDrawerStore, + type LightspeedDrawerState, +} from '../store/lightspeedDrawerStore'; + +const SERVER_SNAPSHOT: LightspeedDrawerState = { + isOpen: false, + displayMode: ChatbotDisplayMode.default, + drawerWidth: DEFAULT_DRAWER_WIDTH, + currentConversationId: undefined, + draftMessage: '', + draftFileContents: [], + shellViewTab: 0, + pendingOverlayThreadHandoff: false, +}; + +const getServerSnapshot = () => SERVER_SNAPSHOT; + +/** + * Hook to access Lightspeed drawer state and actions. + * Backed by a global singleton store — works from any position in the + * React tree without a wrapping Provider. + * + * @internal + */ +export function useLightspeedDrawer(): LightspeedDrawerContextType { + const snapshot = useSyncExternalStore( + lightspeedDrawerStore.subscribe, + lightspeedDrawerStore.getSnapshot, + getServerSnapshot, + ); + + return { + isChatbotActive: snapshot.isOpen, + toggleChatbot: lightspeedDrawerStore.toggle, + displayMode: snapshot.displayMode, + setDisplayMode: lightspeedDrawerStore.setDisplayMode, + drawerWidth: snapshot.drawerWidth, + setDrawerWidth: lightspeedDrawerStore.setDrawerWidth, + currentConversationId: snapshot.currentConversationId, + setCurrentConversationId: lightspeedDrawerStore.setConversationId, + draftMessage: snapshot.draftMessage, + setDraftMessage: lightspeedDrawerStore.setDraftMessage, + draftFileContents: snapshot.draftFileContents, + setDraftFileContents: lightspeedDrawerStore.setDraftFileContents, + consumePendingOverlayThreadHandoff: + lightspeedDrawerStore.consumeThreadHandoff, + shellViewTab: snapshot.shellViewTab, + setShellViewTab: lightspeedDrawerStore.setShellViewTab, + }; +} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx deleted file mode 100644 index ec15a3bdf8..0000000000 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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 { LightspeedDrawerContext } from '../components/LightspeedDrawerContext'; - -/** - * Hook to access the LightspeedDrawerContext - * @public - */ -export const useLightspeedDrawerContext = () => { - const context = useContext(LightspeedDrawerContext); - if (context === undefined) { - throw new Error( - 'useLightspeedDrawerContext must be used within a LightspeedDrawerProvider', - ); - } - return context; -}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts index 13555d3b59..0b50beb9ed 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedProviderState.ts @@ -14,19 +14,16 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'; import { useLocation, useMatch, useNavigate } from 'react-router-dom'; import { ChatbotDisplayMode } from '@patternfly/chatbot'; import { useAppDrawer } from '@red-hat-developer-hub/backstage-plugin-app-react'; -import type { - LightspeedDrawerContextType, - LightspeedEmbeddedNotebooksTarget, -} from '../components/LightspeedDrawerContext'; +import type { LightspeedEmbeddedNotebooksTarget } from '../components/LightspeedDrawerContext'; import { LIGHTSPEED_APP_DRAWER_ID, LIGHTSPEED_PATH } from '../const'; -import type { FileContent } from '../types'; +import { lightspeedDrawerStore } from '../store/lightspeedDrawerStore'; import { useBackstageUserIdentity } from './useBackstageUserIdentity'; import { useDisplayModeSettings } from './useDisplayModeSettings'; @@ -37,12 +34,12 @@ function lightspeedRoutePath(conversationId?: string): string { } /** - * Encapsulates LightspeedDrawerProvider state, routing, and ApplicationDrawer sync. + * Shell hook that orchestrates router sync, AppDrawer integration, and + * registers complex handlers into the lightspeedDrawerStore. * * @internal */ -export function useLightspeedProviderState(): { - contextValue: LightspeedDrawerContextType; +export function useLightspeedShellState(): { shouldRenderOverlayModal: boolean; closeChatbot: () => void; } { @@ -55,40 +52,22 @@ export function useLightspeedProviderState(): { setDisplayMode: setPersistedDisplayMode, } = useDisplayModeSettings(user, ChatbotDisplayMode.default); - const [displayModeState, setDisplayModeState] = - useState(persistedDisplayMode); - const [isOpen, setIsOpen] = useState(false); - const [drawerWidth, setDrawerWidth] = useState(400); - const [currentConversationIdState, setCurrentConversationIdState] = useState< - string | undefined - >(undefined); - const [draftMessage, setDraftMessageState] = useState(''); - 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 snapshot = useSyncExternalStore( + lightspeedDrawerStore.subscribe, + lightspeedDrawerStore.getSnapshot, + ); + 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(null); - const isLightspeedRouteRef = useRef(false); - const persistedDisplayModeRef = useRef(persistedDisplayMode); - const isLightspeedRoute = location.pathname.startsWith(LIGHTSPEED_PATH); + const isLightspeedRouteRef = useRef(isLightspeedRoute); isLightspeedRouteRef.current = isLightspeedRoute; + const persistedDisplayModeRef = useRef(persistedDisplayMode); persistedDisplayModeRef.current = persistedDisplayMode; + const conversationMatch = useMatch( `${LIGHTSPEED_PATH}/conversation/:conversationId`, ); @@ -119,14 +98,11 @@ export function useLightspeedProviderState(): { navigate(-1); }, [navigate]); - /** - * Leaving /lightspeed for overlay/docked must not use navigate(-1): after the first FAB - * open, navigateBackOrGoToCatalog uses -1 and can land back on /lightspeed (fullscreen). - */ const leaveLightspeedRouteForShellDisplayMode = useCallback(() => { navigate('/catalog', { replace: true }); }, [navigate]); + // --- Route → Store sync --- useEffect(() => { const pathname = location.pathname; const prevPathname = lightspeedPathnamePrevRef.current; @@ -142,12 +118,9 @@ export function useLightspeedProviderState(): { } if (conversationId) { - setCurrentConversationIdState(conversationId); + lightspeedDrawerStore.setConversationIdRaw(conversationId); } else if (isLightspeedRoute) { - // On `/lightspeed` without a `:conversationId` segment, URL implies a fresh thread. - // When navigating to another app route (overlay/docked), keep the last id so display - // mode switches and re-entry to fullscreen stay on the active conversation. - setCurrentConversationIdState(undefined); + lightspeedDrawerStore.setConversationIdRaw(undefined); } if (isLightspeedRoute) { @@ -156,21 +129,19 @@ export function useLightspeedProviderState(): { leavingLightspeedForNonEmbeddedShellRef.current && persistedDisplayMode !== ChatbotDisplayMode.embedded ) { - setDisplayModeState(persistedDisplayMode); + lightspeedDrawerStore.setDisplayModeRaw(persistedDisplayMode); } else { - setDisplayModeState(ChatbotDisplayMode.embedded); + lightspeedDrawerStore.setDisplayModeRaw(ChatbotDisplayMode.embedded); } } - setIsOpen(true); + lightspeedDrawerStore.open(); if (!dockedAfterLeavingFullscreenRef.current) { closeDrawer(LIGHTSPEED_APP_DRAWER_ID); } } else if (persistedDisplayMode === ChatbotDisplayMode.embedded) { - // Off /lightspeed there is no fullscreen surface; use overlay so FAB stays available. - // (Persisted preference remains "fullscreen" for the next open.) - setDisplayModeState(ChatbotDisplayMode.default); + lightspeedDrawerStore.setDisplayModeRaw(ChatbotDisplayMode.default); } else { - setDisplayModeState(persistedDisplayMode); + lightspeedDrawerStore.setDisplayModeRaw(persistedDisplayMode); } lightspeedPathnamePrevRef.current = pathname; @@ -182,36 +153,40 @@ export function useLightspeedProviderState(): { persistedDisplayMode, ]); + // --- Docked mode → AppDrawer sync --- useEffect(() => { if ( !isLightspeedRoute && - isOpen && - displayModeState === ChatbotDisplayMode.docked + snapshot.isOpen && + snapshot.displayMode === ChatbotDisplayMode.docked ) { openDrawer(LIGHTSPEED_APP_DRAWER_ID); dockedAfterLeavingFullscreenRef.current = false; } - }, [displayModeState, isLightspeedRoute, isOpen, openDrawer]); + }, [snapshot.displayMode, isLightspeedRoute, snapshot.isOpen, openDrawer]); + + // --- Orchestrated actions --- const openChatbot = useCallback(() => { openedViaFABRef.current = true; const rawMode = persistedDisplayMode || ChatbotDisplayMode.default; + const currentSnapshot = lightspeedDrawerStore.getSnapshot(); if (rawMode === ChatbotDisplayMode.embedded) { if (!isLightspeedRoute) { - if (shellViewTabRef.current === 1) { + if (currentSnapshot.shellViewTab === 1) { navigate(`${LIGHTSPEED_PATH}/notebooks`); } else { - navigate(lightspeedRoutePath(currentConversationIdState)); + navigate(lightspeedRoutePath(currentSnapshot.currentConversationId)); } } - setDisplayModeState(ChatbotDisplayMode.embedded); + lightspeedDrawerStore.setDisplayMode(ChatbotDisplayMode.embedded); closeDrawer(LIGHTSPEED_APP_DRAWER_ID); - setIsOpen(true); + lightspeedDrawerStore.open(); return; } - setDisplayModeState(rawMode); + lightspeedDrawerStore.setDisplayMode(rawMode); if (rawMode === ChatbotDisplayMode.docked) { openDrawer(LIGHTSPEED_APP_DRAWER_ID); @@ -219,10 +194,9 @@ export function useLightspeedProviderState(): { closeDrawer(LIGHTSPEED_APP_DRAWER_ID); } - setIsOpen(true); + lightspeedDrawerStore.open(); }, [ closeDrawer, - currentConversationIdState, isLightspeedRoute, navigate, openDrawer, @@ -230,34 +204,31 @@ export function useLightspeedProviderState(): { ]); const closeChatbot = useCallback(() => { + const currentSnapshot = lightspeedDrawerStore.getSnapshot(); dockedAfterLeavingFullscreenRef.current = false; - if (displayModeState === ChatbotDisplayMode.embedded && isLightspeedRoute) { + if ( + currentSnapshot.displayMode === ChatbotDisplayMode.embedded && + isLightspeedRoute + ) { navigateBackOrGoToCatalog(); } - if (displayModeState === ChatbotDisplayMode.docked) { + if (currentSnapshot.displayMode === ChatbotDisplayMode.docked) { closeDrawer(LIGHTSPEED_APP_DRAWER_ID); } - setIsOpen(false); - }, [ - closeDrawer, - displayModeState, - isLightspeedRoute, - navigateBackOrGoToCatalog, - ]); + lightspeedDrawerStore.close(); + }, [closeDrawer, isLightspeedRoute, navigateBackOrGoToCatalog]); const toggleChatbot = useCallback(() => { - if (isOpen) { + if (snapshot.isOpen) { closeChatbot(); } else { openChatbot(); } - }, [isOpen, openChatbot, closeChatbot]); + }, [snapshot.isOpen, openChatbot, closeChatbot]); const setCurrentConversationId = useCallback( (id: string | undefined) => { - setCurrentConversationIdState(id); - // Refs: first-stream completion calls onStart after unmount / mode change; a stale - // embedded + /lightspeed closure would navigate back to fullscreen without this. + lightspeedDrawerStore.setConversationIdRaw(id); if ( persistedDisplayModeRef.current === ChatbotDisplayMode.embedded && isLightspeedRouteRef.current @@ -268,29 +239,14 @@ export function useLightspeedProviderState(): { [navigate], ); - const setDraftMessage = useCallback((message: string) => { - setDraftMessageState(message); - }, []); - - const setDraftFileContents = useCallback((files: FileContent[]) => { - setDraftFileContentsState(files); - }, []); - - const consumePendingOverlayThreadHandoff = useCallback(() => { - if (!pendingOverlayThreadHandoffRef.current) { - return false; - } - pendingOverlayThreadHandoffRef.current = false; - return true; - }, []); - - const setDisplayMode = useCallback( + const orchestratedSetDisplayMode = useCallback( ( mode: ChatbotDisplayMode, conversationIdParam?: string, embeddedNotebooks?: LightspeedEmbeddedNotebooksTarget, ) => { - if (mode === displayModeState) { + const currentSnapshot = lightspeedDrawerStore.getSnapshot(); + if (mode === currentSnapshot.displayMode) { return; } setPersistedDisplayMode(mode); @@ -303,80 +259,53 @@ export function useLightspeedProviderState(): { ? `${LIGHTSPEED_PATH}/notebooks` : `${LIGHTSPEED_PATH}/notebooks/${embeddedNotebooks.notebookSessionId}`; navigate(path); - } else if (shellViewTabRef.current === 1) { + } else if (currentSnapshot.shellViewTab === 1) { navigate(`${LIGHTSPEED_PATH}/notebooks`); } else { - const convId = conversationIdParam ?? currentConversationIdState; + const convId = + conversationIdParam ?? currentSnapshot.currentConversationId; navigate(lightspeedRoutePath(convId)); } - setIsOpen(true); + lightspeedDrawerStore.open(); } 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); + lightspeedDrawerStore.setShellViewTab(0); if (isLightspeedRoute) { leavingLightspeedForNonEmbeddedShellRef.current = true; - pendingOverlayThreadHandoffRef.current = true; + lightspeedDrawerStore.setPendingOverlayThreadHandoff(true); leaveLightspeedRouteForShellDisplayMode(); } - setIsOpen(true); + lightspeedDrawerStore.open(); } + + lightspeedDrawerStore.setDisplayModeRaw(mode); }, [ - currentConversationIdState, - displayModeState, isLightspeedRoute, leaveLightspeedRouteForShellDisplayMode, navigate, setPersistedDisplayMode, - setShellViewTab, syncShellDrawerForMode, ], ); - const shouldRenderOverlayModal = - isOpen && - displayModeState === ChatbotDisplayMode.default && - !isLightspeedRoute; - - const contextValue = useMemo( - () => ({ - isChatbotActive: isOpen, - toggleChatbot, - displayMode: displayModeState, - setDisplayMode, - drawerWidth, - setDrawerWidth, - currentConversationId: currentConversationIdState, - setCurrentConversationId, - draftMessage, - setDraftMessage, - draftFileContents, - setDraftFileContents, - consumePendingOverlayThreadHandoff, - shellViewTab, - setShellViewTab, - }), - [ - isOpen, + // --- Register orchestrated handlers --- + useEffect(() => { + lightspeedDrawerStore.registerHandlers({ + setDisplayMode: orchestratedSetDisplayMode, toggleChatbot, - displayModeState, - setDisplayMode, - drawerWidth, - currentConversationIdState, setCurrentConversationId, - draftMessage, - setDraftMessage, - draftFileContents, - setDraftFileContents, - consumePendingOverlayThreadHandoff, - shellViewTab, - setShellViewTab, - ], - ); + }); + return () => { + lightspeedDrawerStore.unregisterHandlers(); + }; + }, [orchestratedSetDisplayMode, toggleChatbot, setCurrentConversationId]); + + const shouldRenderOverlayModal = + snapshot.isOpen && + snapshot.displayMode === ChatbotDisplayMode.default && + !isLightspeedRoute; return { - contextValue, shouldRenderOverlayModal, closeChatbot, }; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/index.tsx b/workspaces/lightspeed/plugins/lightspeed/src/index.tsx index df01fb8846..e9c463dc5d 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/index.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/index.tsx @@ -202,16 +202,3 @@ export default createFrontendPlugin({ lightspeedConversation: nfsConversationRouteRef, }, }); - -// Legacy (OFS) re-exports for backwards compatibility. -// Deprecated — will be removed in a future release. Use './legacy' subpath for new OFS consumers. -export { - lightspeedPlugin, - LightspeedPage, - LightspeedDrawerProvider, - LightspeedIcon, - LightspeedFAB, - LightspeedChatContainer, - LightspeedDrawerStateExposer, -} from './legacy'; -export type { DrawerStateExposerProps, DrawerState } from './legacy'; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/store/lightspeedDrawerStore.ts b/workspaces/lightspeed/plugins/lightspeed/src/store/lightspeedDrawerStore.ts new file mode 100644 index 0000000000..f47fc70d6c --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/store/lightspeedDrawerStore.ts @@ -0,0 +1,207 @@ +/* + * 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; + +import { ChatbotDisplayMode } from '@patternfly/chatbot'; + +import type { LightspeedEmbeddedNotebooksTarget } from '../components/LightspeedDrawerContext'; +import type { FileContent } from '../types'; + +/** @internal */ +export const DEFAULT_DRAWER_WIDTH = 400; + +/** @internal */ +export interface LightspeedDrawerState { + isOpen: boolean; + displayMode: ChatbotDisplayMode; + drawerWidth: number; + currentConversationId: string | undefined; + draftMessage: string; + draftFileContents: FileContent[]; + shellViewTab: number; + pendingOverlayThreadHandoff: boolean; +} + +/** + * Orchestrated handlers registered by the router-bridge shell. + * These contain navigation logic that requires React Router hooks. + * @internal + */ +export interface LightspeedDrawerHandlers { + setDisplayMode: ( + mode: ChatbotDisplayMode, + conversationIdParam?: string, + embeddedNotebooks?: LightspeedEmbeddedNotebooksTarget, + ) => void; + toggleChatbot: () => void; + setCurrentConversationId: (id: string | undefined) => void; +} + +function getInitialState(): LightspeedDrawerState { + return { + isOpen: false, + displayMode: ChatbotDisplayMode.default, + drawerWidth: DEFAULT_DRAWER_WIDTH, + currentConversationId: undefined, + draftMessage: '', + draftFileContents: [], + shellViewTab: 0, + pendingOverlayThreadHandoff: false, + }; +} + +function createLightspeedDrawerStore() { + let state: LightspeedDrawerState = getInitialState(); + const listeners = new Set<() => void>(); + let handlers: LightspeedDrawerHandlers | null = null; + + function emit() { + listeners.forEach(l => l()); + } + + function update(fn: (prev: LightspeedDrawerState) => LightspeedDrawerState) { + state = fn(state); + emit(); + } + + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getSnapshot() { + return state; + }, + + // --- Orchestration handler registration (called by the shell) --- + + registerHandlers(h: LightspeedDrawerHandlers) { + handlers = h; + }, + + unregisterHandlers() { + handlers = null; + }, + + getHandlers() { + return handlers; + }, + + // --- Simple state setters (always available) --- + + open() { + update(s => ({ ...s, isOpen: true })); + }, + + close() { + update(s => ({ ...s, isOpen: false })); + }, + + toggle() { + if (handlers) { + handlers.toggleChatbot(); + } else { + update(s => ({ ...s, isOpen: !s.isOpen })); + } + }, + + /** Raw setter — updates state without routing side effects. Used by the shell internally. */ + setDisplayModeRaw(mode: ChatbotDisplayMode) { + update(s => ({ ...s, displayMode: mode })); + }, + + setDisplayMode( + mode: ChatbotDisplayMode, + conversationIdParam?: string, + embeddedNotebooks?: LightspeedEmbeddedNotebooksTarget, + ) { + if (handlers) { + handlers.setDisplayMode(mode, conversationIdParam, embeddedNotebooks); + } else { + update(s => ({ ...s, displayMode: mode })); + } + }, + + setDrawerWidth(width: number) { + update(s => ({ ...s, drawerWidth: width })); + }, + + /** Raw setter — updates state without routing side effects. Used by the shell internally. */ + setConversationIdRaw(id: string | undefined) { + update(s => ({ ...s, currentConversationId: id })); + }, + + setConversationId(id: string | undefined) { + if (handlers) { + handlers.setCurrentConversationId(id); + } else { + update(s => ({ ...s, currentConversationId: id })); + } + }, + + setDraftMessage(msg: string) { + update(s => ({ ...s, draftMessage: msg })); + }, + + setDraftFileContents(files: FileContent[]) { + update(s => ({ ...s, draftFileContents: files })); + }, + + setShellViewTab(tab: number) { + const next = tab === 1 ? 1 : 0; + update(s => ({ ...s, shellViewTab: next })); + }, + + setPendingOverlayThreadHandoff(pending: boolean) { + update(s => ({ ...s, pendingOverlayThreadHandoff: pending })); + }, + + consumeThreadHandoff(): boolean { + if (!state.pendingOverlayThreadHandoff) { + return false; + } + update(s => ({ ...s, pendingOverlayThreadHandoff: false })); + return true; + }, + + /** @internal — test helper */ + reset() { + state = getInitialState(); + handlers = null; + emit(); + }, + }; +} + +/** + * Global Lightspeed drawer store backed by `@backstage/version-bridge` singleton. + * Enables `useLightspeedDrawer()` to work from any position in the React tree + * without requiring a wrapping Provider. + * + * @internal + */ +export const lightspeedDrawerStore = getOrCreateGlobalSingleton( + 'rhdh-lightspeed-drawer', + createLightspeedDrawerStore, +); + +export type LightspeedDrawerStore = ReturnType< + typeof createLightspeedDrawerStore +>; diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock index b5230c4b8a..0f7a182e04 100644 --- a/workspaces/lightspeed/yarn.lock +++ b/workspaces/lightspeed/yarn.lock @@ -3406,7 +3406,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-common@npm:^1.1.8, @backstage/plugin-catalog-common@npm:^1.1.9": +"@backstage/plugin-catalog-common@npm:^1.1.8": version: 1.1.9 resolution: "@backstage/plugin-catalog-common@npm:1.1.9" dependencies: @@ -3488,27 +3488,27 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-node@npm:^2.1.0": - version: 2.2.0 - resolution: "@backstage/plugin-catalog-node@npm:2.2.0" +"@backstage/plugin-catalog-node@npm:2.1.0": + version: 2.1.0 + resolution: "@backstage/plugin-catalog-node@npm:2.1.0" dependencies: - "@backstage/backend-plugin-api": "npm:^1.9.0" - "@backstage/catalog-client": "npm:^1.15.0" - "@backstage/catalog-model": "npm:^1.8.0" - "@backstage/errors": "npm:^1.3.0" - "@backstage/plugin-catalog-common": "npm:^1.1.9" - "@backstage/plugin-permission-common": "npm:^0.9.8" - "@backstage/plugin-permission-node": "npm:^0.10.12" + "@backstage/backend-plugin-api": "npm:^1.8.0" + "@backstage/catalog-client": "npm:^1.14.0" + "@backstage/catalog-model": "npm:^1.7.7" + "@backstage/errors": "npm:^1.2.7" + "@backstage/plugin-catalog-common": "npm:^1.1.8" + "@backstage/plugin-permission-common": "npm:^0.9.7" + "@backstage/plugin-permission-node": "npm:^0.10.11" "@backstage/types": "npm:^1.2.2" "@opentelemetry/api": "npm:^1.9.0" lodash: "npm:^4.17.21" yaml: "npm:^2.0.0" peerDependencies: - "@backstage/backend-test-utils": ^1.11.2 + "@backstage/backend-test-utils": ^1.11.1 peerDependenciesMeta: "@backstage/backend-test-utils": optional: true - checksum: 10c0/b04a8c9c4aa5523e221c51ed87bdd2cd8ea837783a53612cb82014c75f9189d21da9bf88652018cd63c5fdf1385d4e677099bb8979a8add2aaadc9d5c51ed297 + checksum: 10c0/1c4e122dbd418b31cf728a57a34766c24ad3bf331923b5df7213a61e69b90ae9b093635888c1a4a0c0184ca4771940f63072d7706ed871acdb73120b43306e5d languageName: node linkType: hard @@ -9951,6 +9951,7 @@ __metadata: "@backstage/plugin-permission-react": "npm:^0.4.41" "@backstage/test-utils": "npm:^1.7.16" "@backstage/theme": "npm:^0.7.2" + "@backstage/version-bridge": "npm:^1.0.12" "@emotion/is-prop-valid": "npm:^1.3.1" "@ianvs/prettier-plugin-sort-imports": "npm:^4.4.0" "@material-ui/core": "npm:^4.9.13"