(400);
+ const [currentConversationIdState, setCurrentConversationIdState] = useState<
+ string | undefined
+ >(undefined);
+
+ const isLightspeedRoute = location.pathname.startsWith('/lightspeed');
+
+ useEffect(() => {
+ if (isLightspeedRoute) {
+ const match = location.pathname.match(/\/lightspeed\/conversation\/(.+)/);
+ if (match) {
+ setCurrentConversationIdState(match[1]);
+ } else {
+ setCurrentConversationIdState(undefined);
+ }
+ setDisplayModeState(ChatbotDisplayMode.embedded);
+ setIsOpen(true);
+ } else if (displayModeState === ChatbotDisplayMode.embedded) {
+ setDisplayModeState(ChatbotDisplayMode.default);
+ }
+ }, [isLightspeedRoute, location.pathname, displayModeState]);
+
+ // Open chatbot in overlay mode
+ const openChatbot = useCallback(() => {
+ setDisplayModeState(ChatbotDisplayMode.default);
+ setIsOpen(true);
+ }, []);
+
+ // Close chatbot
+ const closeChatbot = useCallback(() => {
+ // If in embedded mode on the lightspeed route, navigate back
+ if (displayModeState === ChatbotDisplayMode.embedded && isLightspeedRoute) {
+ navigate(-1);
+ }
+ setIsOpen(false);
+ setDisplayModeState(ChatbotDisplayMode.default);
+ }, [displayModeState, isLightspeedRoute, navigate]);
+
+ const toggleChatbot = useCallback(() => {
+ if (isOpen) {
+ closeChatbot();
+ } else {
+ openChatbot();
+ }
+ }, [isOpen, openChatbot, closeChatbot]);
+
+ const setCurrentConversationId = useCallback(
+ (id: string | undefined) => {
+ setCurrentConversationIdState(id);
+
+ // Update route if in embedded mode
+ if (
+ displayModeState === ChatbotDisplayMode.embedded &&
+ isLightspeedRoute
+ ) {
+ const path = id ? `/lightspeed/conversation/${id}` : '/lightspeed';
+ navigate(path, { replace: true });
+ }
+ },
+ [displayModeState, isLightspeedRoute, navigate],
+ );
+
+ // Set display mode with route handling for embedded/fullscreen
+ const setDisplayMode = useCallback(
+ (mode: ChatbotDisplayMode, conversationId?: string) => {
+ setDisplayModeState(mode);
+
+ // Navigate to fullscreen route with conversation ID if available
+ if (mode === ChatbotDisplayMode.embedded) {
+ const convId = conversationId ?? currentConversationIdState;
+ const path = convId
+ ? `/lightspeed/conversation/${convId}`
+ : '/lightspeed';
+ navigate(path);
+ setIsOpen(true);
+ } else {
+ if (isLightspeedRoute) {
+ navigate(-1);
+ }
+ setIsOpen(true);
+ }
+ },
+ [navigate, isLightspeedRoute, currentConversationIdState],
+ );
+
+ // Only render ChatbotModal for overlay mode
+ // Docked mode is handled by ApplicationDrawer in Root
+ // Embedded mode is handled by LightspeedPage route
+ const shouldRenderOverlayModal =
+ isOpen &&
+ displayModeState === ChatbotDisplayMode.default &&
+ !isLightspeedRoute;
+
+ const contextValue = useMemo(
+ () => ({
+ isChatbotActive: isOpen,
+ toggleChatbot,
+ displayMode: displayModeState,
+ setDisplayMode,
+ drawerWidth,
+ setDrawerWidth,
+ currentConversationId: currentConversationIdState,
+ setCurrentConversationId,
+ }),
+ [
+ isOpen,
+ toggleChatbot,
+ displayModeState,
+ setDisplayMode,
+ drawerWidth,
+ setDrawerWidth,
+ currentConversationIdState,
+ setCurrentConversationId,
+ ],
+ );
+
+ return (
+
+ {children}
+ {shouldRenderOverlayModal && (
+
+
+
+ )}
+
+ );
+};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx
new file mode 100644
index 0000000000..9154cd39de
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedDrawerStateExposer.tsx
@@ -0,0 +1,67 @@
+/*
+ * 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 { useEffect } from 'react';
+
+import { ChatbotDisplayMode } from '@patternfly/chatbot';
+
+import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext';
+
+/**
+ * @public
+ * Partial Lightspeed drawer state exposed to the ApplicationDrawer
+ */
+export type DrawerState = {
+ id: string;
+ isDrawerOpen: boolean;
+ drawerWidth: number;
+ setDrawerWidth: (width: number) => void;
+};
+
+/**
+ * @public
+ * Props for drawer state exposer components
+ */
+export type DrawerStateExposerProps = {
+ /**
+ * Callback called whenever the drawer state changes
+ */
+ onStateChange: (state: DrawerState) => void;
+};
+
+/**
+ * @public
+ * This exposes LightspeedDrawer's partial context to the ApplicationDrawer
+ *
+ * It reads the LightspeedDrawerContext and calls the onStateChange callback with the
+ * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth).
+
+ */
+export const LightspeedDrawerStateExposer = ({
+ onStateChange,
+}: DrawerStateExposerProps) => {
+ const { displayMode, drawerWidth, setDrawerWidth } =
+ useLightspeedDrawerContext();
+ useEffect(() => {
+ onStateChange({
+ id: 'lightspeed',
+ isDrawerOpen: displayMode === ChatbotDisplayMode.docked,
+ drawerWidth,
+ setDrawerWidth,
+ });
+ }, [displayMode, drawerWidth, onStateChange, setDrawerWidth]);
+ return null;
+};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx
new file mode 100644
index 0000000000..b4778ed7bd
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedFAB.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 Close from '@mui/icons-material/Close';
+import Fab from '@mui/material/Fab';
+import Tooltip from '@mui/material/Tooltip';
+import { makeStyles } from '@mui/styles';
+import { ChatbotDisplayMode } from '@patternfly/chatbot';
+
+import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext';
+import { LightspeedFABIcon } from './LightspeedIcon';
+
+const useStyles = makeStyles(theme => ({
+ 'fab-button': {
+ bottom: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`,
+ right: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`,
+ alignItems: 'end',
+ zIndex: 200,
+ display: 'flex',
+ position: 'fixed',
+
+ // When drawer is docked, adjust margin
+ '.docked-drawer-open &': {
+ transition: 'margin-right 0.3s ease',
+ marginRight: 'var(--docked-drawer-width, 500px) ',
+ },
+ },
+}));
+
+/**
+ * @public
+ * Lightspeed Floating action button to open/close the lightspeed chatbot
+ */
+
+export const LightspeedFAB = () => {
+ const { isChatbotActive, toggleChatbot, displayMode } =
+ useLightspeedDrawerContext();
+ const fabButton = useStyles();
+
+ if (displayMode === ChatbotDisplayMode.embedded) {
+ return null;
+ }
+
+ return (
+
+
+
+ {isChatbotActive ? : }
+
+
+
+ );
+};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx
index 0af2b2abb5..40bbeb38de 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedIcon.tsx
@@ -16,6 +16,7 @@
import { useTranslation } from '../hooks/useTranslation';
import logo from '../images/logo-white.svg';
+import roundedLogo from '../images/rounded-logo.svg';
/**
* @public
@@ -32,3 +33,23 @@ export const LightspeedIcon = () => {
/>
);
};
+
+/**
+ * @public
+ * Lightspeed FAB Icon */
+export const LightspeedFABIcon = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx
index b7ef8b4765..aec6f2cde6 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx
@@ -14,23 +14,12 @@
* limitations under the License.
*/
-import { useEffect, useMemo, useState } from 'react';
-import { useAsync } from 'react-use';
-
import { Content, Header, Page } from '@backstage/core-components';
-import { identityApiRef, useApi } from '@backstage/core-plugin-api';
-import { createStyles, makeStyles, useTheme } from '@material-ui/core/styles';
-import { QueryClientProvider } from '@tanstack/react-query';
+import { createStyles, makeStyles } from '@material-ui/core/styles';
-import { useAllModels } from '../hooks/useAllModels';
-import { useLightspeedViewPermission } from '../hooks/useLightspeedViewPermission';
-import { useTopicRestrictionStatus } from '../hooks/useQuestionValidation';
import { useTranslation } from '../hooks/useTranslation';
-import queryClient from '../utils/queryClient';
-import FileAttachmentContextProvider from './AttachmentContext';
-import { LightspeedChat } from './LightSpeedChat';
-import PermissionRequiredState from './PermissionRequiredState';
+import { LightspeedChatContainer } from './LightspeedChatContainer';
const useStyles = makeStyles(() =>
createStyles({
@@ -40,112 +29,13 @@ const useStyles = makeStyles(() =>
}),
);
-const THEME_DARK = 'dark';
-const THEME_DARK_CLASS = 'pf-v6-theme-dark';
-const LAST_SELECTED_MODEL_KEY = 'lastSelectedModel';
-
-const LightspeedPageInner = () => {
+/**
+ * Lightspeed Page - Routable fullscreen/embedded mode
+ * @public
+ */
+export const LightspeedPage = () => {
const classes = useStyles();
const { t } = useTranslation();
- const {
- palette: { type },
- } = useTheme();
-
- const identityApi = useApi(identityApiRef);
-
- const { data: models } = useAllModels();
-
- const { allowed: hasViewAccess, loading } = useLightspeedViewPermission();
-
- const { value: profile, loading: profileLoading } = useAsync(
- async () => await identityApi.getProfileInfo(),
- );
-
- const [selectedModel, setSelectedModel] = useState('');
- const [selectedProvider, setSelectedProvider] = useState('');
-
- const { data: topicRestrictionEnabled } = useTopicRestrictionStatus();
-
- const modelsItems = useMemo(
- () =>
- models
- ? models
- .filter(model => model.model_type === 'llm')
- .map(m => ({
- label: m.provider_resource_id,
- value: m.provider_resource_id,
- provider: m.provider_id,
- }))
- : [],
- [models],
- );
-
- useEffect(() => {
- const htmlTagElement = document.documentElement;
- if (type === THEME_DARK) {
- htmlTagElement.classList.add(THEME_DARK_CLASS);
- } else {
- htmlTagElement.classList.remove(THEME_DARK_CLASS);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [type]);
-
- useEffect(() => {
- if (modelsItems.length > 0) {
- try {
- const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
- const parsedData = storedData ? JSON.parse(storedData) : null;
-
- // Check if stored model exists in available models
- const storedModel = parsedData?.model
- ? modelsItems.find(m => m.value === parsedData.model)
- : null;
-
- if (storedModel) {
- setSelectedModel(storedModel.value);
- setSelectedProvider(storedModel.provider);
- } else {
- // Fallback to first model if stored model is not available
- setSelectedModel(modelsItems[0].value);
- setSelectedProvider(modelsItems[0].provider);
- }
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error(
- 'Error loading last selected model from localStorage:',
- error,
- );
- // Fallback to first model on error
- setSelectedModel(modelsItems[0].value);
- setSelectedProvider(modelsItems[0].provider);
- }
- }
- }, [modelsItems]);
-
- // Save to localStorage whenever model or provider changes
- useEffect(() => {
- if (selectedModel && selectedProvider) {
- try {
- localStorage.setItem(
- LAST_SELECTED_MODEL_KEY,
- JSON.stringify({
- model: selectedModel,
- provider: selectedProvider,
- }),
- );
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error(
- 'Error saving last selected model to localStorage:',
- error,
- );
- }
- }
- }, [selectedModel, selectedProvider]);
-
- if (loading) {
- return null;
- }
return (
@@ -155,37 +45,8 @@ const LightspeedPageInner = () => {
pageTitleOverride={t('page.title')}
/>
- {!hasViewAccess ? (
-
- ) : (
-
- {
- setSelectedModel(item);
- setSelectedProvider(
- modelsItems.find((m: any) => m.value === item)?.provider ||
- '',
- );
- }}
- models={modelsItems}
- userName={profile?.displayName}
- avatar={profile?.picture}
- profileLoading={profileLoading}
- />
-
- )}
+
);
};
-
-export const LightspeedPage = () => {
- return (
-
-
-
- );
-};
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 aaf24c1fa2..1918d1cdb4 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx
@@ -21,6 +21,7 @@ import {
import { usePermission } from '@backstage/plugin-permission-react';
import { mockApis, TestApiProvider } from '@backstage/test-utils';
+import { ChatbotDisplayMode } from '@patternfly/chatbot';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
@@ -33,6 +34,7 @@ import userEvent from '@testing-library/user-event';
import { lightspeedApiRef } from '../../api/api';
import { useConversations } from '../../hooks';
+import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext';
import { mockUseTranslation } from '../../test-utils/mockTranslations';
import FileAttachmentContextProvider from '../AttachmentContext';
import { LightspeedChat } from '../LightSpeedChat';
@@ -88,6 +90,10 @@ jest.mock('../../hooks/useTranslation', () => ({
useTranslation: jest.fn(() => mockUseTranslation()),
}));
+jest.mock('../../hooks/useLightspeedDrawerContext', () => ({
+ useLightspeedDrawerContext: jest.fn(),
+}));
+
jest.mock('@patternfly/chatbot', () => {
const actual = jest.requireActual('@patternfly/chatbot');
return {
@@ -100,6 +106,10 @@ const mockUseConversations = useConversations as jest.Mock;
const mockUsePermission = usePermission as jest.MockedFunction<
typeof usePermission
>;
+const mockUseLightspeedDrawerContext =
+ useLightspeedDrawerContext as jest.MockedFunction<
+ typeof useLightspeedDrawerContext
+ >;
const configAPi = mockApis.config({});
@@ -140,6 +150,9 @@ const setupLightspeedChat = () => (
);
describe('LightspeedChat', () => {
+ const mockSetDisplayMode = jest.fn();
+ const mockSetCurrentConversationId = jest.fn();
+
beforeEach(() => {
mockUsePermission.mockReturnValue({ loading: true, allowed: true });
mockUseConversations.mockReturnValue({
@@ -147,9 +160,25 @@ describe('LightspeedChat', () => {
isRefetching: false,
isLoading: false,
});
+ mockUseLightspeedDrawerContext.mockReturnValue({
+ isChatbotActive: false,
+ toggleChatbot: jest.fn(),
+ displayMode: ChatbotDisplayMode.embedded,
+ setDisplayMode: mockSetDisplayMode,
+ drawerWidth: 500,
+ setDrawerWidth: jest.fn(),
+ currentConversationId: undefined,
+ setCurrentConversationId: mockSetCurrentConversationId,
+ });
localStorage.clear();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
});
+
const localStorageKey = 'lastOpenedConversation';
const mockUser = 'user:test';
@@ -361,4 +390,169 @@ describe('LightspeedChat', () => {
expect(searchInput).toHaveValue('xyz123nonexistent');
});
});
+
+ describe('displayMode selection from settings dropdown', () => {
+ it('should open settings dropdown when clicking the settings button', async () => {
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ expect(settingsButton).toBeInTheDocument();
+
+ await userEvent.click(settingsButton);
+
+ // Verify dropdown is open with display mode options
+ await waitFor(() => {
+ expect(screen.getByText('Display mode')).toBeInTheDocument();
+ });
+ });
+
+ it('should show all display mode options in the dropdown', async () => {
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Display mode')).toBeInTheDocument();
+ expect(screen.getByText('Overlay')).toBeInTheDocument();
+ expect(screen.getByText('Dock to window')).toBeInTheDocument();
+ expect(screen.getByText('Fullscreen')).toBeInTheDocument();
+ });
+ });
+
+ it('should call setDisplayMode with default when clicking Overlay option', async () => {
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Overlay')).toBeInTheDocument();
+ });
+
+ const overlayOption = screen.getByText('Overlay');
+ await userEvent.click(overlayOption);
+
+ expect(mockSetDisplayMode).toHaveBeenCalledWith(
+ ChatbotDisplayMode.default,
+ );
+ });
+
+ it('should call setDisplayMode with docked when clicking Dock to window option', async () => {
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Dock to window')).toBeInTheDocument();
+ });
+
+ const dockedOption = screen.getByText('Dock to window');
+ await userEvent.click(dockedOption);
+
+ expect(mockSetDisplayMode).toHaveBeenCalledWith(
+ ChatbotDisplayMode.docked,
+ );
+ });
+
+ it('should call setDisplayMode with embedded when clicking Fullscreen option', async () => {
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Fullscreen')).toBeInTheDocument();
+ });
+
+ const fullscreenOption = screen.getByText('Fullscreen');
+ await userEvent.click(fullscreenOption);
+
+ expect(mockSetDisplayMode).toHaveBeenCalledWith(
+ ChatbotDisplayMode.embedded,
+ );
+ });
+
+ it('should show current display mode as selected in full-screen mode', async () => {
+ mockUseLightspeedDrawerContext.mockReturnValue({
+ isChatbotActive: false,
+ toggleChatbot: jest.fn(),
+ displayMode: ChatbotDisplayMode.embedded,
+ setDisplayMode: mockSetDisplayMode,
+ drawerWidth: 500,
+ setDrawerWidth: jest.fn(),
+ currentConversationId: undefined,
+ setCurrentConversationId: mockSetCurrentConversationId,
+ });
+
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Fullscreen')).toBeInTheDocument();
+ });
+
+ const fullscreenOption = screen
+ .getByText('Fullscreen')
+ .closest('button, li, [role="menuitem"]');
+ expect(fullscreenOption).toHaveClass('pf-m-selected');
+ });
+
+ it('should show current display mode as selected 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,
+ });
+
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Dock to window')).toBeInTheDocument();
+ });
+
+ const dockedOption = screen
+ .getByText('Dock to window')
+ .closest('button, li, [role="menuitem"]');
+ expect(dockedOption).toHaveClass('pf-m-selected');
+ });
+
+ it('should show current display mode as selected in overlay mode', async () => {
+ mockUseLightspeedDrawerContext.mockReturnValue({
+ isChatbotActive: false,
+ toggleChatbot: jest.fn(),
+ displayMode: ChatbotDisplayMode.default,
+ setDisplayMode: mockSetDisplayMode,
+ drawerWidth: 500,
+ setDrawerWidth: jest.fn(),
+ currentConversationId: undefined,
+ setCurrentConversationId: mockSetCurrentConversationId,
+ });
+
+ render(setupLightspeedChat());
+
+ const settingsButton = screen.getByLabelText('Chatbot options');
+ await userEvent.click(settingsButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Overlay')).toBeInTheDocument();
+ });
+
+ const overlayOption = screen
+ .getByText('Overlay')
+ .closest('button, li, [role="menuitem"]');
+ expect(overlayOption).toHaveClass('pf-m-selected');
+ });
+ });
});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx
new file mode 100644
index 0000000000..45fc04d6ee
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx
@@ -0,0 +1,219 @@
+/*
+ * 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 { render, waitFor } from '@testing-library/react';
+
+import { LightspeedDrawerContext } from '../LightspeedDrawerContext';
+import {
+ DrawerState,
+ LightspeedDrawerStateExposer,
+} from '../LightspeedDrawerStateExposer';
+
+describe('LightspeedDrawerStateExposer', () => {
+ const mockSetDrawerWidth = jest.fn();
+ const mockOnStateChange = jest.fn();
+
+ const createContextValue = (overrides = {}) => ({
+ isChatbotActive: false,
+ toggleChatbot: jest.fn(),
+ displayMode: ChatbotDisplayMode.default,
+ setDisplayMode: jest.fn(),
+ drawerWidth: 500,
+ setDrawerWidth: mockSetDrawerWidth,
+ currentConversationId: undefined,
+ setCurrentConversationId: jest.fn(),
+ ...overrides,
+ });
+
+ const renderWithContext = (
+ contextValue: ReturnType,
+ ) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should call onStateChange with initial state', async () => {
+ renderWithContext(createContextValue());
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith({
+ id: 'lightspeed',
+ isDrawerOpen: false,
+ drawerWidth: 500,
+ setDrawerWidth: mockSetDrawerWidth,
+ });
+ });
+ });
+
+ it('should set isDrawerOpen to true when displayMode is docked', async () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.docked,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isDrawerOpen: true,
+ }),
+ );
+ });
+ });
+
+ it('should set isDrawerOpen to false when displayMode is overlay', async () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isDrawerOpen: false,
+ }),
+ );
+ });
+ });
+
+ it('should set isDrawerOpen to false when displayMode is fullscreen', async () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.fullscreen,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isDrawerOpen: false,
+ }),
+ );
+ });
+ });
+
+ it('should include the correct drawerWidth in state', async () => {
+ renderWithContext(
+ createContextValue({
+ 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,
+ }),
+ );
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isDrawerOpen: false,
+ }),
+ );
+ });
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockOnStateChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isDrawerOpen: true,
+ }),
+ );
+ });
+ });
+
+ it('should render null', () => {
+ const { container } = renderWithContext(createContextValue());
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should always use lightspeed as the id', async () => {
+ renderWithContext(createContextValue());
+
+ await waitFor(() => {
+ const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState;
+ expect(callArg.id).toBe('lightspeed');
+ });
+ });
+
+ it('should pass setDrawerWidth function in state', async () => {
+ renderWithContext(createContextValue());
+
+ await waitFor(() => {
+ const callArg = mockOnStateChange.mock.calls[0][0] as DrawerState;
+ expect(callArg.setDrawerWidth).toBe(mockSetDrawerWidth);
+ });
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx
new file mode 100644
index 0000000000..2fb4f50f42
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedFAB.test.tsx
@@ -0,0 +1,173 @@
+/*
+ * 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 { fireEvent, render, screen } from '@testing-library/react';
+
+import { mockUseTranslation } from '../../test-utils/mockTranslations';
+import { LightspeedDrawerContext } from '../LightspeedDrawerContext';
+import { LightspeedFAB } from '../LightspeedFAB';
+
+jest.mock('../../hooks/useTranslation', () => ({
+ useTranslation: jest.fn(() => mockUseTranslation()),
+}));
+
+describe('LightspeedFAB', () => {
+ const mockToggleChatbot = jest.fn();
+
+ const createContextValue = (overrides = {}) => ({
+ isChatbotActive: false,
+ toggleChatbot: mockToggleChatbot,
+ displayMode: ChatbotDisplayMode.default,
+ setDisplayMode: jest.fn(),
+ drawerWidth: 500,
+ setDrawerWidth: jest.fn(),
+ currentConversationId: undefined,
+ setCurrentConversationId: jest.fn(),
+ ...overrides,
+ });
+
+ const renderWithContext = (
+ contextValue: ReturnType,
+ ) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render FAB button when displayMode is overlay', () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument();
+ expect(screen.getByLabelText('lightspeed-fab')).toBeInTheDocument();
+ });
+
+ it('should render FAB button when displayMode is docked', () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.docked,
+ }),
+ );
+
+ expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument();
+ });
+
+ it('should not render FAB button when displayMode is embedded', () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.embedded,
+ }),
+ );
+
+ expect(screen.queryByTestId('lightspeed-fab')).not.toBeInTheDocument();
+ });
+
+ it('should call toggleChatbot when FAB button is clicked', () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ const fabButton = screen.getByLabelText('lightspeed-fab');
+ fireEvent.click(fabButton);
+
+ expect(mockToggleChatbot).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show close icon when chatbot is active', () => {
+ renderWithContext(
+ createContextValue({
+ isChatbotActive: true,
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ // Close icon should be rendered (MUI Close icon)
+ expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
+ });
+
+ it('should show LightspeedFABIcon when chatbot is not active', () => {
+ renderWithContext(
+ createContextValue({
+ isChatbotActive: false,
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ // The icon should be an image with the lightspeed alt text
+ expect(screen.getByTestId('lightspeed-fab-icon')).toBeInTheDocument();
+ });
+
+ it('should have correct tooltip when chatbot is inactive', async () => {
+ renderWithContext(
+ createContextValue({
+ isChatbotActive: false,
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ // Tooltip should show "Open Lightspeed"
+ const fabButton = screen.getByLabelText('lightspeed-fab');
+ expect(fabButton).toBeInTheDocument();
+ });
+
+ it('should have correct tooltip when chatbot is active', async () => {
+ renderWithContext(
+ createContextValue({
+ isChatbotActive: true,
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ // Tooltip should show "Close Lightspeed"
+ const fabButton = screen.getByLabelText('lightspeed-fab');
+ expect(fabButton).toBeInTheDocument();
+ });
+
+ it('should render with correct id attribute', () => {
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.default,
+ }),
+ );
+
+ const fabContainer = screen.getByTestId('lightspeed-fab');
+ expect(fabContainer).toHaveAttribute('id', 'lightspeed-fab');
+ });
+
+ it('should not render when displayMode is fullscreen', () => {
+ // Fullscreen is essentially the same as embedded in behavior
+ renderWithContext(
+ createContextValue({
+ displayMode: ChatbotDisplayMode.fullscreen,
+ }),
+ );
+
+ // Fullscreen mode should still show the FAB (it's not embedded)
+ expect(screen.getByTestId('lightspeed-fab')).toBeInTheDocument();
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx
new file mode 100644
index 0000000000..ca7ae126bc
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedIcon.test.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+
+import { mockUseTranslation } from '../../test-utils/mockTranslations';
+import { LightspeedFABIcon, LightspeedIcon } from '../LightspeedIcon';
+
+jest.mock('../../hooks/useTranslation', () => ({
+ useTranslation: jest.fn(() => mockUseTranslation()),
+}));
+
+describe('LightspeedIcon', () => {
+ it('should render the lightspeed icon with correct alt text', () => {
+ render();
+
+ const img = screen.getByRole('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('alt', 'lightspeed icon');
+ expect(img).toHaveStyle({ height: '25px' });
+ });
+});
+
+describe('LightspeedFABIcon', () => {
+ it('should render the FAB icon with correct alt text', () => {
+ render();
+
+ const img = screen.getByRole('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('data-testid', 'lightspeed-fab-icon');
+ expect(img).toHaveStyle({
+ width: '100%',
+ height: '100%',
+ display: 'block',
+ });
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx
new file mode 100644
index 0000000000..75140d5bf7
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx
@@ -0,0 +1,220 @@
+/*
+ * 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(),
+ };
+
+ 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');
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx
new file mode 100644
index 0000000000..ec15a3bdf8
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLightspeedDrawerContext.tsx
@@ -0,0 +1,33 @@
+/*
+ * 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/images/rounded-logo.svg b/workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg
new file mode 100644
index 0000000000..0ffe452a80
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/images/rounded-logo.svg
@@ -0,0 +1,22 @@
+
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/index.ts b/workspaces/lightspeed/plugins/lightspeed/src/index.ts
index caab458393..676550f9b3 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/index.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/index.ts
@@ -14,5 +14,34 @@
* limitations under the License.
*/
-export { lightspeedPlugin, LightspeedPage } from './plugin';
-export { LightspeedIcon } from './components/LightspeedIcon';
+export {
+ lightspeedPlugin,
+ LightspeedPage,
+ LightspeedDrawerProvider,
+} from './plugin';
+export { LightspeedIcon, LightspeedFABIcon } from './components/LightspeedIcon';
+export { LightspeedFAB } from './components/LightspeedFAB';
+export { LightspeedChatContainer } from './components/LightspeedChatContainer';
+export { LightspeedDrawerStateExposer } from './components/LightspeedDrawerStateExposer';
+export { useLightspeedDrawerContext } from './hooks/useLightspeedDrawerContext';
+export { lightspeedApiRef } from './api/api';
+export { LightspeedApiClient } from './api/LightspeedApiClient';
+export type { LightspeedDrawerContextType } from './components/LightspeedDrawerContext';
+export type {
+ DrawerStateExposerProps,
+ DrawerState,
+} from './components/LightspeedDrawerStateExposer';
+export type { LightspeedAPI } from './api/api';
+export type { Options } from './api/LightspeedApiClient';
+export type {
+ LCSModel,
+ LCSModelType,
+ LCSModelApiModelType,
+ BaseMessage,
+ Attachment,
+ ConversationList,
+ ConversationSummary,
+ CaptureFeedback,
+ ReferencedDocuments,
+ ReferencedDocument,
+} from './types';
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts
index 0f96071688..4a8ccf8a32 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/plugin.ts
@@ -17,9 +17,12 @@
import '@patternfly/react-core/dist/styles/base-no-reset.css';
import '@patternfly/chatbot/dist/css/main.css';
+import { PropsWithChildren } from 'react';
+
import {
configApiRef,
createApiFactory,
+ createComponentExtension,
createPlugin,
createRoutableExtension,
fetchApiRef,
@@ -63,3 +66,70 @@ export const LightspeedPage = lightspeedPlugin.provide(
mountPoint: rootRouteRef,
}),
);
+
+/**
+ * Lightspeed Drawer Provider
+ *
+ * @public
+ */
+export const LightspeedDrawerProvider: React.ComponentType =
+ lightspeedPlugin.provide(
+ createComponentExtension({
+ name: 'LightspeedDrawerProvider',
+ component: {
+ lazy: () =>
+ import('./components/LightspeedDrawerProvider').then(
+ m => m.LightspeedDrawerProvider,
+ ),
+ },
+ }),
+ );
+
+/**
+ * Lightspeed FAB for global floating action button fot LightspeedAI
+ *
+ * @public
+ */
+export const LightspeedFAB: React.ComponentType = lightspeedPlugin.provide(
+ createComponentExtension({
+ name: 'LightspeedFAB',
+ component: {
+ lazy: () =>
+ import('./components/LightspeedFAB').then(m => m.LightspeedFAB),
+ },
+ }),
+);
+
+/**
+ * Lightspeed Drawer State Exposer exposes its drawer state
+ *
+ * @public
+ */
+export const LightspeedDrawerStateExposer = lightspeedPlugin.provide(
+ createComponentExtension({
+ name: 'LightspeedDrawerStateExposer',
+ component: {
+ lazy: () =>
+ import('./components/LightspeedDrawerStateExposer').then(
+ m => m.LightspeedDrawerStateExposer,
+ ),
+ },
+ }),
+);
+
+/**
+ * Lightspeed Chat Container component extension
+ *
+ * @public
+ */
+export const LightspeedChatContainer = lightspeedPlugin.provide(
+ createComponentExtension({
+ name: 'LightspeedChatContainer',
+ component: {
+ lazy: () =>
+ import('./components/LightspeedChatContainer').then(
+ m => m.LightspeedChatContainer,
+ ),
+ },
+ }),
+);
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts
index e3a9106c1e..a5487c7611 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts
@@ -138,6 +138,7 @@ const lightspeedTranslationDe = createTranslationMessages({
'aria.scroll.down': 'Nach unten',
'aria.scroll.up': 'Nach oben',
'aria.settings.label': 'Chatbot-Optionen',
+ 'aria.close': 'Chatbot schließen',
// Modal actions
'modal.edit': 'Bearbeiten',
@@ -168,6 +169,7 @@ const lightspeedTranslationDe = createTranslationMessages({
'tooltip.backToTop': 'Nach oben',
'tooltip.backToBottom': 'Nach unten',
'tooltip.settings': 'Chatbot-Optionen',
+ 'tooltip.close': 'Schließen',
// Modal titles
'modal.title.preview': 'Anhang-Vorschau',
@@ -227,6 +229,12 @@ const lightspeedTranslationDe = createTranslationMessages({
'Angeheftete Chats sind derzeit aktiviert',
'settings.pinned.disabled.description':
'Angeheftete Chats sind derzeit deaktiviert',
+
+ // Display modes
+ 'settings.displayMode.label': 'Anzeigemodus',
+ 'settings.displayMode.overlay': 'Überlagerung',
+ 'settings.displayMode.docked': 'An Fenster andocken',
+ 'settings.displayMode.fullscreen': 'Vollbild',
},
});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts
index 13a13a2abc..de0a1f7e5c 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts
@@ -142,6 +142,7 @@ const lightspeedTranslationEs = createTranslationMessages({
'aria.scroll.down': 'Volver abajo',
'aria.scroll.up': 'Volver arriba',
'aria.settings.label': 'Opciones del chatbot',
+ 'aria.close': 'Cerrar chatbot',
// Modal actions
'modal.edit': 'Editar',
@@ -172,6 +173,7 @@ const lightspeedTranslationEs = createTranslationMessages({
'tooltip.backToTop': 'Volver arriba',
'tooltip.backToBottom': 'Volver abajo',
'tooltip.settings': 'Opciones del chatbot',
+ 'tooltip.close': 'Cerrar',
// Modal titles
'modal.title.preview': 'Vista previa del adjunto',
@@ -230,6 +232,12 @@ const lightspeedTranslationEs = createTranslationMessages({
'Los chats fijados están actualmente habilitados',
'settings.pinned.disabled.description':
'Los chats fijados están actualmente deshabilitados',
+
+ // Display modes
+ 'settings.displayMode.label': 'Modo de visualización',
+ 'settings.displayMode.overlay': 'Superposición',
+ 'settings.displayMode.docked': 'Acoplar a ventana',
+ 'settings.displayMode.fullscreen': 'Pantalla completa',
},
});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts
index d2a15643aa..386bd04ce6 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts
@@ -141,6 +141,7 @@ const lightspeedTranslationFr = createTranslationMessages({
'aria.scroll.down': 'Retour en bas',
'aria.scroll.up': 'Retour en haut',
'aria.settings.label': 'Options du chatbot',
+ 'aria.close': 'Fermer le chatbot',
// Modal actions
'modal.edit': 'Modifier',
@@ -171,6 +172,7 @@ const lightspeedTranslationFr = createTranslationMessages({
'tooltip.backToTop': 'Retour en haut',
'tooltip.backToBottom': 'Retour en bas',
'tooltip.settings': 'Options du chatbot',
+ 'tooltip.close': 'Fermer',
// Modal titles
'modal.title.preview': 'Aperçu de la pièce jointe',
@@ -231,6 +233,12 @@ const lightspeedTranslationFr = createTranslationMessages({
'Les chats épinglés sont actuellement activés',
'settings.pinned.disabled.description':
'Les chats épinglés sont actuellement désactivés',
+
+ // Display modes
+ 'settings.displayMode.label': "Mode d'affichage",
+ 'settings.displayMode.overlay': 'Superposition',
+ 'settings.displayMode.docked': 'Ancrer à la fenêtre',
+ 'settings.displayMode.fullscreen': 'Plein écran',
},
});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
index 486e8cdf1d..aa6802a55c 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
@@ -134,6 +134,7 @@ export const lightspeedMessages = {
'aria.scroll.down': 'Back to bottom',
'aria.scroll.up': 'Back to top',
'aria.settings.label': 'Chatbot options',
+ 'aria.close': 'Close chatbot',
// Modal actions
'modal.edit': 'Edit',
@@ -164,6 +165,7 @@ export const lightspeedMessages = {
'tooltip.backToTop': 'Back to top',
'tooltip.backToBottom': 'Back to bottom',
'tooltip.settings': 'Chatbot options',
+ 'tooltip.close': 'Close',
// Modal titles
'modal.title.preview': 'Preview attachment',
@@ -218,6 +220,12 @@ export const lightspeedMessages = {
'settings.pinned.disable': 'Disable pinned chats',
'settings.pinned.enabled.description': 'Pinned chats are currently enabled',
'settings.pinned.disabled.description': 'Pinned chats are currently disabled',
+
+ // Display modes
+ 'settings.displayMode.label': 'Display mode',
+ 'settings.displayMode.overlay': 'Overlay',
+ 'settings.displayMode.docked': 'Dock to window',
+ 'settings.displayMode.fullscreen': 'Fullscreen',
};
/**
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts
index 7cf98576f8..9ead725755 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts
@@ -29,17 +29,38 @@ export type Conversations = {
};
};
+/**
+ * @public
+ * Referenced document metadata
+ */
export type ReferencedDocument = {
doc_title: string;
doc_url: string;
doc_description?: string;
};
+/**
+ * @public
+ * List of referenced documents
+ */
export type ReferencedDocuments = ReferencedDocument[];
+/**
+ * @public
+ * LCS model type - embedding or llm
+ */
export type LCSModelType = 'embedding' | 'llm';
+
+/**
+ * @public
+ * LCS model API model type
+ */
export type LCSModelApiModelType = 'embedding' | 'llm';
+/**
+ * @public
+ * LCS Model interface
+ */
export interface LCSModel {
identifier: string;
metadata: {
@@ -67,6 +88,11 @@ export interface LCSShield {
params: {};
provider_resource_id: string;
}
+
+/**
+ * @public
+ * Base message interface for chat messages
+ */
export interface BaseMessage {
name: string;
type: string;
@@ -78,6 +104,11 @@ export interface BaseMessage {
referenced_documents?: ReferencedDocuments;
error?: AlertProps;
}
+
+/**
+ * @public
+ * Conversation summary type
+ */
export type ConversationSummary = {
conversation_id: string;
last_message_timestamp: number;
@@ -95,12 +126,20 @@ export interface FileContent {
name: string;
}
+/**
+ * @public
+ * Attachment type for file attachments in chat messages
+ */
export type Attachment = {
attachment_type: string;
content_type: string;
content: string;
};
+/**
+ * @public
+ * List of conversation summaries
+ */
export type ConversationList = ConversationSummary[];
export type SamplePrompt =
@@ -115,6 +154,10 @@ export type SamplePrompt =
export type SamplePrompts = SamplePrompt[];
+/**
+ * @public
+ * Feedback capture payload for user feedback on AI responses
+ */
export type CaptureFeedback = {
conversation_id: string;
user_question: string;
diff --git a/workspaces/lightspeed/yarn.lock b/workspaces/lightspeed/yarn.lock
index bba71afed5..18ab8bcdce 100644
--- a/workspaces/lightspeed/yarn.lock
+++ b/workspaces/lightspeed/yarn.lock
@@ -4955,16 +4955,16 @@ __metadata:
languageName: node
linkType: hard
-"@emotion/cache@npm:^11.11.0, @emotion/cache@npm:^11.13.0":
- version: 11.13.1
- resolution: "@emotion/cache@npm:11.13.1"
+"@emotion/cache@npm:^11.13.0, @emotion/cache@npm:^11.13.5":
+ version: 11.14.0
+ resolution: "@emotion/cache@npm:11.14.0"
dependencies:
"@emotion/memoize": ^0.9.0
"@emotion/sheet": ^1.4.0
- "@emotion/utils": ^1.4.0
+ "@emotion/utils": ^1.4.2
"@emotion/weak-memoize": ^0.4.0
stylis: 4.2.0
- checksum: 94b161786a03a08a1e30257478fad9a9be1ac8585ddca0c6410d7411fd474fc8b0d6d1167d7d15bdb012d1fd8a1220ac2bbc79501ad9b292b83c17da0874d7de
+ checksum: 0a81591541ea43bc7851742e6444b7800d72e98006f94e775ae6ea0806662d14e0a86ff940f5f19d33b4bb2c427c882aa65d417e7322a6e0d5f20fe65ed920c9
languageName: node
linkType: hard
@@ -4975,7 +4975,7 @@ __metadata:
languageName: node
linkType: hard
-"@emotion/hash@npm:^0.9.2":
+"@emotion/hash@npm:^0.9.1, @emotion/hash@npm:^0.9.2":
version: 0.9.2
resolution: "@emotion/hash@npm:0.9.2"
checksum: 379bde2830ccb0328c2617ec009642321c0e009a46aa383dfbe75b679c6aea977ca698c832d225a893901f29d7b3eef0e38cf341f560f6b2b56f1ff23c172387
@@ -5035,16 +5035,16 @@ __metadata:
languageName: node
linkType: hard
-"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1":
- version: 1.3.2
- resolution: "@emotion/serialize@npm:1.3.2"
+"@emotion/serialize@npm:^1.2.0, @emotion/serialize@npm:^1.3.0, @emotion/serialize@npm:^1.3.1, @emotion/serialize@npm:^1.3.3":
+ version: 1.3.3
+ resolution: "@emotion/serialize@npm:1.3.3"
dependencies:
"@emotion/hash": ^0.9.2
"@emotion/memoize": ^0.9.0
"@emotion/unitless": ^0.10.0
- "@emotion/utils": ^1.4.1
+ "@emotion/utils": ^1.4.2
csstype: ^3.0.2
- checksum: 8051bafe32459e1aecf716cdb66a22b090060806104cca89d4e664893b56878d3e9bb94a4657df9b7b3fd183700a9be72f7144c959ddcbd3cf7b330206919237
+ checksum: 510331233767ae4e09e925287ca2c7269b320fa1d737ea86db5b3c861a734483ea832394c0c1fe5b21468fe335624a75e72818831d303ba38125f54f44ba02e7
languageName: node
linkType: hard
@@ -5091,10 +5091,10 @@ __metadata:
languageName: node
linkType: hard
-"@emotion/utils@npm:^1.4.0, @emotion/utils@npm:^1.4.1":
- version: 1.4.1
- resolution: "@emotion/utils@npm:1.4.1"
- checksum: 088f6844c735981f53c84a76101cf261422301e7895cb37fea6a47e7950247ffa8ca174ca2a15d9b29a47f0fa831b432017ca7683bccbb5cfd78dda82743d856
+"@emotion/utils@npm:^1.4.0, @emotion/utils@npm:^1.4.2":
+ version: 1.4.2
+ resolution: "@emotion/utils@npm:1.4.2"
+ checksum: 04cf76849c6401205c058b82689fd0ec5bf501aed6974880fe9681a1d61543efb97e848f4c38664ac4a9068c7ad2d1cb84f73bde6cf95f1208aa3c28e0190321
languageName: node
linkType: hard
@@ -7571,10 +7571,26 @@ __metadata:
languageName: node
linkType: hard
-"@mui/core-downloads-tracker@npm:^5.16.7":
- version: 5.16.7
- resolution: "@mui/core-downloads-tracker@npm:5.16.7"
- checksum: b65c48ba2bf6bba6435ba9f2d6c33db0c8a85b3ff7599136a9682b72205bec76470ab5ed5e6e625d5bd012ed9bcbc641ed677548be80d217c9fb5d0435567062
+"@mui/core-downloads-tracker@npm:^5.18.0":
+ version: 5.18.0
+ resolution: "@mui/core-downloads-tracker@npm:5.18.0"
+ checksum: 065b46739d2bd84b880ad2f6a0a2062d60e3a296ce18ff380cad22ab5b2cb3de396755f322f4bea3a422ffffe1a9244536fc3c9623056ff3873c996e6664b1b9
+ languageName: node
+ linkType: hard
+
+"@mui/icons-material@npm:^5.15.17, @mui/icons-material@npm:^5.17.1":
+ version: 5.18.0
+ resolution: "@mui/icons-material@npm:5.18.0"
+ dependencies:
+ "@babel/runtime": ^7.23.9
+ peerDependencies:
+ "@mui/material": ^5.0.0
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 1ee4ee2817278c72095860c81e742270ee01fd320e48f865d371abfeacf83e43a7c6edbfd060ee66ef2a4f02c6cf79de0aca1e73a4d5f6320112bbfcac8176c3
languageName: node
linkType: hard
@@ -7594,28 +7610,28 @@ __metadata:
languageName: node
linkType: hard
-"@mui/material@npm:^5.12.2":
- version: 5.16.7
- resolution: "@mui/material@npm:5.16.7"
+"@mui/material@npm:^5.12.2, @mui/material@npm:^5.15.17":
+ version: 5.18.0
+ resolution: "@mui/material@npm:5.18.0"
dependencies:
"@babel/runtime": ^7.23.9
- "@mui/core-downloads-tracker": ^5.16.7
- "@mui/system": ^5.16.7
- "@mui/types": ^7.2.15
- "@mui/utils": ^5.16.6
+ "@mui/core-downloads-tracker": ^5.18.0
+ "@mui/system": ^5.18.0
+ "@mui/types": ~7.2.15
+ "@mui/utils": ^5.17.1
"@popperjs/core": ^2.11.8
"@types/react-transition-group": ^4.4.10
clsx: ^2.1.0
csstype: ^3.1.3
prop-types: ^15.8.1
- react-is: ^18.3.1
+ react-is: ^19.0.0
react-transition-group: ^4.4.5
peerDependencies:
"@emotion/react": ^11.5.0
"@emotion/styled": ^11.3.0
- "@types/react": ^17.0.0 || ^18.0.0
- react: ^17.0.0 || ^18.0.0
- react-dom: ^17.0.0 || ^18.0.0
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/react":
optional: true
@@ -7623,65 +7639,97 @@ __metadata:
optional: true
"@types/react":
optional: true
- checksum: 5057b48c3ce554247de9a8f675bda9bbda079bc83a696c500525f3ebbd63315a44f1c2a7c83c2025dbd02d2722892e397a0af10c1219d45f6534e41d91a43cc0
+ checksum: b9d6cf638774e65924adf6f58ad50639c55050b33dc6223aa74686f733e137d173b4594b28be70e8ea3baf413a7fcd3bee40d35e3af12a42b7ba03def71ea217
languageName: node
linkType: hard
-"@mui/private-theming@npm:^5.16.6":
- version: 5.16.6
- resolution: "@mui/private-theming@npm:5.16.6"
+"@mui/private-theming@npm:^5.17.1":
+ version: 5.17.1
+ resolution: "@mui/private-theming@npm:5.17.1"
dependencies:
"@babel/runtime": ^7.23.9
- "@mui/utils": ^5.16.6
+ "@mui/utils": ^5.17.1
prop-types: ^15.8.1
peerDependencies:
- "@types/react": ^17.0.0 || ^18.0.0
- react: ^17.0.0 || ^18.0.0
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
- checksum: 314ba598ab17cd425a36e4cab677ed26fe0939b23e53120da77cfbc3be6dada5428fa8e2a55cb697417599a4e3abfee6d4711de0a7318b9fb2c3a822b2d5b5a8
+ checksum: ab755e96c8adb6c12f2c7e567154a8d873d56f1e35d0fb897b568123dfc0e9d1d770ca0992410433c728f9f4403f556f3c1e1b5b57b001ef1948ddfc8c1717bd
languageName: node
linkType: hard
-"@mui/styled-engine@npm:^5.16.6":
- version: 5.16.6
- resolution: "@mui/styled-engine@npm:5.16.6"
+"@mui/styled-engine@npm:^5.18.0":
+ version: 5.18.0
+ resolution: "@mui/styled-engine@npm:5.18.0"
dependencies:
"@babel/runtime": ^7.23.9
- "@emotion/cache": ^11.11.0
+ "@emotion/cache": ^11.13.5
+ "@emotion/serialize": ^1.3.3
csstype: ^3.1.3
prop-types: ^15.8.1
peerDependencies:
"@emotion/react": ^11.4.1
"@emotion/styled": ^11.3.0
- react: ^17.0.0 || ^18.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/react":
optional: true
"@emotion/styled":
optional: true
- checksum: 604f83b91801945336db211a8273061132668d01e9f456c30bb811a3b49cc5786b8b7dd8e0b5b89de15f6209abc900d9e679d3ae7a4651a6df45e323b6ed95c5
+ checksum: ab2d260ad5eea94993bc7706b164ae4ec11bd37dd7be36b93755d18da5b7859d39ad44173adced0e111e8b1b7ef65c0e369df3f2908144237c5e78f793eebb5a
languageName: node
linkType: hard
-"@mui/system@npm:^5.16.7":
- version: 5.16.7
- resolution: "@mui/system@npm:5.16.7"
+"@mui/styles@npm:5.18.0":
+ version: 5.18.0
+ resolution: "@mui/styles@npm:5.18.0"
dependencies:
"@babel/runtime": ^7.23.9
- "@mui/private-theming": ^5.16.6
- "@mui/styled-engine": ^5.16.6
- "@mui/types": ^7.2.15
- "@mui/utils": ^5.16.6
+ "@emotion/hash": ^0.9.1
+ "@mui/private-theming": ^5.17.1
+ "@mui/types": ~7.2.15
+ "@mui/utils": ^5.17.1
+ clsx: ^2.1.0
+ csstype: ^3.1.3
+ hoist-non-react-statics: ^3.3.2
+ jss: ^10.10.0
+ jss-plugin-camel-case: ^10.10.0
+ jss-plugin-default-unit: ^10.10.0
+ jss-plugin-global: ^10.10.0
+ jss-plugin-nested: ^10.10.0
+ jss-plugin-props-sort: ^10.10.0
+ jss-plugin-rule-value-function: ^10.10.0
+ jss-plugin-vendor-prefixer: ^10.10.0
+ prop-types: ^15.8.1
+ peerDependencies:
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 290cd7b7d738277c7024ac3f0840b2fd9f973008bd18a67294a8e1f401ffbca992311101c9af5cc1b799abd8c3ec469a0096c5d8cccc6b540218c433b0040f28
+ languageName: node
+ linkType: hard
+
+"@mui/system@npm:^5.18.0":
+ version: 5.18.0
+ resolution: "@mui/system@npm:5.18.0"
+ dependencies:
+ "@babel/runtime": ^7.23.9
+ "@mui/private-theming": ^5.17.1
+ "@mui/styled-engine": ^5.18.0
+ "@mui/types": ~7.2.15
+ "@mui/utils": ^5.17.1
clsx: ^2.1.0
csstype: ^3.1.3
prop-types: ^15.8.1
peerDependencies:
"@emotion/react": ^11.5.0
"@emotion/styled": ^11.3.0
- "@types/react": ^17.0.0 || ^18.0.0
- react: ^17.0.0 || ^18.0.0
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/react":
optional: true
@@ -7689,39 +7737,39 @@ __metadata:
optional: true
"@types/react":
optional: true
- checksum: 86cc11d062645b6742328178ca3a9e2aa2c6d064a559e4fb8c6c6bb8251794959b9dad385f9508fdcab2ae2764503c80f7c3d4f6eb1e0e8aa649f28d4f59133b
+ checksum: 451f43889c2638da7c52b898e6174eafcdbcbcaaf3a79f954fdd58d9a043786d76fa4fca902cfdd6ab1aa250f5b1f932ef93d789c5a15987d893c0741bf7a1ad
languageName: node
linkType: hard
-"@mui/types@npm:^7.2.15":
- version: 7.2.19
- resolution: "@mui/types@npm:7.2.19"
+"@mui/types@npm:~7.2.15":
+ version: 7.2.24
+ resolution: "@mui/types@npm:7.2.24"
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
- checksum: c3b5723e6f0861d47df834c57878f19347aefecdaf948cf9a25a64b73fbc75791430693d0f540b2bdc01bdfc605dc32bf4ba738113ec415aa9eaf002ce38f064
+ checksum: 3a7367f9503e90fc3cce78885b57b54a00f7a04108be8af957fdc882c1bc0af68390920ea3d6aef855e704651ffd4a530e36ccbec4d0f421a176a2c3c432bb95
languageName: node
linkType: hard
-"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.16.6":
- version: 5.16.6
- resolution: "@mui/utils@npm:5.16.6"
+"@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.17.1":
+ version: 5.17.1
+ resolution: "@mui/utils@npm:5.17.1"
dependencies:
"@babel/runtime": ^7.23.9
- "@mui/types": ^7.2.15
+ "@mui/types": ~7.2.15
"@types/prop-types": ^15.7.12
clsx: ^2.1.1
prop-types: ^15.8.1
- react-is: ^18.3.1
+ react-is: ^19.0.0
peerDependencies:
- "@types/react": ^17.0.0 || ^18.0.0
- react: ^17.0.0 || ^18.0.0
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
- checksum: 6f8068f07f60a842fcb2e2540eecbd9c5f04df695bcc427184720e8ae138ae689fefd3c20147ab7c76e809ede6e10f5e08d1c34cd3a8b09bd22d2020a666a96f
+ checksum: 06f9da7025b9291f1052c0af012fd0f00ff1539bc99880e15f483a85c27bff0e9c3d047511dc5c3177d546c21978227ece2e6f7a7bd91903c72b48b89c45a677
languageName: node
linkType: hard
@@ -8555,6 +8603,20 @@ __metadata:
languageName: node
linkType: hard
+"@openshift/dynamic-plugin-sdk@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "@openshift/dynamic-plugin-sdk@npm:5.0.1"
+ dependencies:
+ lodash: ^4.17.21
+ semver: ^7.3.7
+ uuid: ^8.3.2
+ yup: ^0.32.11
+ peerDependencies:
+ react: ^17 || ^18
+ checksum: 66e54691ac257cee70b83e627d8fa13c1b0951cc322da0b7c0e2511b9467a9adcf59290ce9250a8ed1de9f98870d6100195afe5dce49ad9b88cf4fc3c991ad4e
+ languageName: node
+ linkType: hard
+
"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.0":
version: 1.9.0
resolution: "@opentelemetry/api@npm:1.9.0"
@@ -11005,6 +11067,27 @@ __metadata:
languageName: node
linkType: hard
+"@red-hat-developer-hub/backstage-plugin-global-floating-action-button@npm:^1.6.1":
+ version: 1.7.0
+ resolution: "@red-hat-developer-hub/backstage-plugin-global-floating-action-button@npm:1.7.0"
+ dependencies:
+ "@backstage/core-components": ^0.18.3
+ "@backstage/core-plugin-api": ^1.12.0
+ "@backstage/theme": ^0.7.0
+ "@mui/icons-material": ^5.15.17
+ "@mui/material": ^5.15.17
+ "@mui/styles": 5.18.0
+ "@scalprum/react-core": 0.9.5
+ classnames: ^2.5.1
+ react-use: ^17.2.4
+ peerDependencies:
+ react: 16.13.1 || ^17.0.0 || ^18.0.0
+ react-dom: 16.13.1 || ^17.0.0 || ^18.0.0
+ react-router-dom: ^6.0.0
+ checksum: 7787420c0ec93941fe47f650982071402ad6ef2560c2d6a9376b721d492959080e76ebdd08858128c134c662af9d7036da149127f5156fd48d6bf13c149c7e5c
+ languageName: node
+ linkType: hard
+
"@red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:^, @red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:plugins/lightspeed-backend":
version: 0.0.0-use.local
resolution: "@red-hat-developer-hub/backstage-plugin-lightspeed-backend@workspace:plugins/lightspeed-backend"
@@ -11065,10 +11148,12 @@ __metadata:
"@material-ui/lab": ^4.0.0-alpha.61
"@mui/icons-material": ^6.1.8
"@mui/material": ^5.12.2
+ "@mui/styles": 5.18.0
"@patternfly/chatbot": 6.4.1
"@patternfly/react-core": 6.4.0
"@patternfly/react-icons": ^6.3.1
"@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^"
+ "@red-hat-developer-hub/backstage-plugin-theme": ^0.10.0
"@spotify/prettier-config": ^15.0.0
"@tanstack/react-query": ^5.59.15
"@testing-library/dom": ^10.0.0
@@ -11088,6 +11173,38 @@ __metadata:
languageName: unknown
linkType: soft
+"@red-hat-developer-hub/backstage-plugin-theme@npm:^0.10.0":
+ version: 0.10.2
+ resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.10.2"
+ dependencies:
+ "@mui/icons-material": ^5.17.1
+ peerDependencies:
+ "@backstage/core-plugin-api": ^1.10.9
+ "@backstage/theme": ^0.6.8
+ "@material-ui/icons": ^4.11.3
+ "@mui/icons-material": ^5.17.1
+ "@mui/material": ^5.0.0
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ checksum: 02b73685dbbb14f82d274e2cc45bb924a887d3492345101835800592f0ac752342595e5866c23661f103a7052c6b21803a5f525d22090391aa7107d3f9f607ea
+ languageName: node
+ linkType: hard
+
+"@red-hat-developer-hub/backstage-plugin-theme@npm:^0.11.0":
+ version: 0.11.0
+ resolution: "@red-hat-developer-hub/backstage-plugin-theme@npm:0.11.0"
+ dependencies:
+ "@mui/icons-material": ^5.17.1
+ peerDependencies:
+ "@backstage/core-plugin-api": ^1.11.1
+ "@backstage/theme": ^0.7.0
+ "@material-ui/icons": ^4.11.3
+ "@mui/icons-material": ^5.17.1
+ "@mui/material": ^5.0.0
+ react: ^16.13.1 || ^17.0.0 || ^18.0.0
+ checksum: 70368eb43351805dbea8a6f9b1a7e5e0c50ac6edc2ab7639c2fe48084d783b0d3f7a629225ae0e6ae3fd47b3fccdf2eecc4ad9a913ddec32e948f94fb6ecbd92
+ languageName: node
+ linkType: hard
+
"@redis/bloom@npm:1.2.0":
version: 1.2.0
resolution: "@redis/bloom@npm:1.2.0"
@@ -11688,6 +11805,30 @@ __metadata:
languageName: node
linkType: hard
+"@scalprum/core@npm:^0.8.3":
+ version: 0.8.3
+ resolution: "@scalprum/core@npm:0.8.3"
+ dependencies:
+ "@openshift/dynamic-plugin-sdk": ^5.0.1
+ tslib: ^2.6.2
+ checksum: 31d1d376c795d47cfc7c4cf84d262989f7a62e3c536aedff03cfa6e23bf969713f70f0e3c9035fe46b280a5133c85c416f1cd2b46c98f9822080ac9d7c752819
+ languageName: node
+ linkType: hard
+
+"@scalprum/react-core@npm:0.9.5":
+ version: 0.9.5
+ resolution: "@scalprum/react-core@npm:0.9.5"
+ dependencies:
+ "@openshift/dynamic-plugin-sdk": ^5.0.1
+ "@scalprum/core": ^0.8.3
+ lodash: ^4.17.0
+ peerDependencies:
+ react: ">=16.8.0 || >=17.0.0 || ^18.0.0"
+ react-dom: ">=16.8.0 || >=17.0.0 || ^18.0.0"
+ checksum: 824cc4c53437a3d0da9a760fefa31a6aba436caef7b2cad69838fb43bd19b6301f90d60e33e823628596ef5e7174a208ba0e67ab129688cf122771fc101b0598
+ languageName: node
+ linkType: hard
+
"@scarf/scarf@npm:=1.4.0":
version: 1.4.0
resolution: "@scarf/scarf@npm:1.4.0"
@@ -14204,6 +14345,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/lodash@npm:^4.14.175":
+ version: 4.17.21
+ resolution: "@types/lodash@npm:4.17.21"
+ checksum: e09e3eaf29b18b6c8e130fcbafd8e3c4ecc2110f35255079245e7d1bd310a5a8f93e4e70266533dce672f253bba899c721bc6870097e0a8c5448e0b628136d39
+ languageName: node
+ linkType: hard
+
"@types/long@npm:^4.0.0":
version: 4.0.2
resolution: "@types/long@npm:4.0.2"
@@ -15492,7 +15640,9 @@ __metadata:
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@playwright/test": 1.57.0
+ "@red-hat-developer-hub/backstage-plugin-global-floating-action-button": ^1.6.1
"@red-hat-developer-hub/backstage-plugin-lightspeed": "*"
+ "@red-hat-developer-hub/backstage-plugin-theme": ^0.11.0
"@testing-library/dom": ^9.0.0
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^14.0.0
@@ -24751,7 +24901,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-camel-case@npm:^10.5.1":
+"jss-plugin-camel-case@npm:^10.10.0, jss-plugin-camel-case@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-camel-case@npm:10.10.0"
dependencies:
@@ -24762,7 +24912,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-default-unit@npm:^10.5.1":
+"jss-plugin-default-unit@npm:^10.10.0, jss-plugin-default-unit@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-default-unit@npm:10.10.0"
dependencies:
@@ -24772,7 +24922,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-global@npm:^10.5.1":
+"jss-plugin-global@npm:^10.10.0, jss-plugin-global@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-global@npm:10.10.0"
dependencies:
@@ -24782,7 +24932,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-nested@npm:^10.5.1":
+"jss-plugin-nested@npm:^10.10.0, jss-plugin-nested@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-nested@npm:10.10.0"
dependencies:
@@ -24793,7 +24943,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-props-sort@npm:^10.5.1":
+"jss-plugin-props-sort@npm:^10.10.0, jss-plugin-props-sort@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-props-sort@npm:10.10.0"
dependencies:
@@ -24803,7 +24953,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-rule-value-function@npm:^10.5.1":
+"jss-plugin-rule-value-function@npm:^10.10.0, jss-plugin-rule-value-function@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-rule-value-function@npm:10.10.0"
dependencies:
@@ -24814,7 +24964,7 @@ __metadata:
languageName: node
linkType: hard
-"jss-plugin-vendor-prefixer@npm:^10.5.1":
+"jss-plugin-vendor-prefixer@npm:^10.10.0, jss-plugin-vendor-prefixer@npm:^10.5.1":
version: 10.10.0
resolution: "jss-plugin-vendor-prefixer@npm:10.10.0"
dependencies:
@@ -24825,7 +24975,7 @@ __metadata:
languageName: node
linkType: hard
-"jss@npm:10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0":
+"jss@npm:10.10.0, jss@npm:^10.10.0, jss@npm:^10.5.1, jss@npm:~10.10.0":
version: 10.10.0
resolution: "jss@npm:10.10.0"
dependencies:
@@ -25491,7 +25641,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash@npm:4.17.21, lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.15, lodash@npm:~4.17.21":
+"lodash@npm:4.17.21, lodash@npm:^4.15.0, lodash@npm:^4.16.4, lodash@npm:^4.17.0, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.15, lodash@npm:~4.17.21":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
@@ -27638,6 +27788,13 @@ __metadata:
languageName: node
linkType: hard
+"nanoclone@npm:^0.2.1":
+ version: 0.2.1
+ resolution: "nanoclone@npm:0.2.1"
+ checksum: 96b2954e22f70561f41e20d69856266c65583c2a441dae108f1dc71b716785d2c8038dac5f1d5e92b117aed3825f526b53139e2e5d6e6db8a77cfa35b3b8bf40
+ languageName: node
+ linkType: hard
+
"nanoid@npm:^3.3.7":
version: 3.3.7
resolution: "nanoid@npm:3.3.7"
@@ -30073,6 +30230,13 @@ __metadata:
languageName: node
linkType: hard
+"property-expr@npm:^2.0.4":
+ version: 2.0.6
+ resolution: "property-expr@npm:2.0.6"
+ checksum: 89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab
+ languageName: node
+ linkType: hard
+
"property-information@npm:^5.0.0":
version: 5.6.0
resolution: "property-information@npm:5.6.0"
@@ -30788,13 +30952,20 @@ __metadata:
languageName: node
linkType: hard
-"react-is@npm:^18.0.0, react-is@npm:^18.2.0, react-is@npm:^18.3.1":
+"react-is@npm:^18.0.0, react-is@npm:^18.2.0":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21
languageName: node
linkType: hard
+"react-is@npm:^19.0.0":
+ version: 19.2.3
+ resolution: "react-is@npm:19.2.3"
+ checksum: 3bb317292dc574632ec33093d38b8ff97abb6dc400e7b0375baef9429f148cf5ae0307e37de97358f3fad07edd159cda8fcb9d28aaaf0dcd8d408ee320638b83
+ languageName: node
+ linkType: hard
+
"react-markdown@npm:^8.0.0":
version: 8.0.7
resolution: "react-markdown@npm:8.0.7"
@@ -32281,7 +32452,7 @@ __metadata:
languageName: node
linkType: hard
-"semver@npm:7.6.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3":
+"semver@npm:7.6.3":
version: 7.6.3
resolution: "semver@npm:7.6.3"
bin:
@@ -32308,6 +32479,15 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3":
+ version: 7.7.3
+ resolution: "semver@npm:7.7.3"
+ bin:
+ semver: bin/semver.js
+ checksum: f013a3ee4607857bcd3503b6ac1d80165f7f8ea94f5d55e2d3e33df82fce487aa3313b987abf9b39e0793c83c9fc67b76c36c067625141a9f6f704ae0ea18db2
+ languageName: node
+ linkType: hard
+
"semver@npm:~7.5.4":
version: 7.5.4
resolution: "semver@npm:7.5.4"
@@ -34152,6 +34332,13 @@ __metadata:
languageName: node
linkType: hard
+"toposort@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "toposort@npm:2.0.2"
+ checksum: d64c74b570391c9432873f48e231b439ee56bc49f7cb9780b505cfdf5cb832f808d0bae072515d93834dd6bceca5bb34448b5b4b408335e4d4716eaf68195dcb
+ languageName: node
+ linkType: hard
+
"tosource@npm:^2.0.0-alpha.3":
version: 2.0.0-alpha.3
resolution: "tosource@npm:2.0.0-alpha.3"
@@ -36263,6 +36450,21 @@ __metadata:
languageName: node
linkType: hard
+"yup@npm:^0.32.11":
+ version: 0.32.11
+ resolution: "yup@npm:0.32.11"
+ dependencies:
+ "@babel/runtime": ^7.15.4
+ "@types/lodash": ^4.14.175
+ lodash: ^4.17.21
+ lodash-es: ^4.17.21
+ nanoclone: ^0.2.1
+ property-expr: ^2.0.4
+ toposort: ^2.0.2
+ checksum: 43a16786b47cc910fed4891cebdd89df6d6e31702e9462e8f969c73eac88551ce750732608012201ea6b93802c8847cb0aa27b5d57370640f4ecf30f9f97d4b0
+ languageName: node
+ linkType: hard
+
"zen-observable@npm:^0.10.0":
version: 0.10.0
resolution: "zen-observable@npm:0.10.0"
diff --git a/workspaces/quickstart/.changeset/fancy-symbols-ask.md b/workspaces/quickstart/.changeset/fancy-symbols-ask.md
new file mode 100644
index 0000000000..1e9513805f
--- /dev/null
+++ b/workspaces/quickstart/.changeset/fancy-symbols-ask.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/backstage-plugin-quickstart': patch
+---
+
+separate quickstart content from the drawer container
diff --git a/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx
new file mode 100644
index 0000000000..f1fad3f674
--- /dev/null
+++ b/workspaces/quickstart/packages/app/src/components/Root/ApplicationDrawer.tsx
@@ -0,0 +1,178 @@
+/*
+ * 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 {
+ ComponentType,
+ useState,
+ useCallback,
+ useMemo,
+ useEffect,
+} from 'react';
+import { CustomDrawer } from './CustomDrawer';
+
+/**
+ * Partial drawer state exposed by drawer plugins
+ *
+ * @public
+ */
+export interface DrawerPartialState {
+ id: string;
+ isDrawerOpen: boolean;
+ drawerWidth: number;
+ setDrawerWidth: (width: number) => void;
+}
+
+/**
+ * Props for drawer state exposer components
+ *
+ * @public
+ */
+export interface DrawerStateExposerProps {
+ onStateChange: (state: DrawerPartialState) => void;
+ onUnmount?: (id: string) => void;
+}
+
+/**
+ * Drawer content configuration
+ */
+type DrawerContentType = {
+ id: string;
+ Component: ComponentType;
+ priority?: number;
+ resizable?: boolean;
+};
+
+/**
+ * State exposer component type
+ */
+type StateExposerType = {
+ Component: ComponentType;
+};
+
+export interface ApplicationDrawerProps {
+ /**
+ * Array of drawer content configurations
+ * Maps drawer IDs to their content components
+ */
+ drawerContents: DrawerContentType[];
+ /**
+ * Array of state exposer components from drawer plugins
+ * These are typically mounted via `application/drawer-state` mount point
+ *
+ * In RHDH dynamic plugins, this would come from:
+ * ```yaml
+ * mountPoints:
+ * - mountPoint: application/drawer-state
+ * importName: TestDrawerStateExposer
+ * ```
+ */
+ stateExposers?: StateExposerType[];
+}
+
+export const ApplicationDrawer = ({
+ drawerContents,
+ stateExposers = [],
+}: ApplicationDrawerProps) => {
+ // Collect drawer states from all state exposers
+ const [drawerStates, setDrawerStates] = useState<
+ Record
+ >({});
+
+ // Callback for state exposers to report their state
+ const handleStateChange = useCallback((state: DrawerPartialState) => {
+ setDrawerStates(prev => {
+ // Only update if something actually changed
+ const existing = prev[state.id];
+ if (
+ existing &&
+ existing.isDrawerOpen === state.isDrawerOpen &&
+ existing.drawerWidth === state.drawerWidth &&
+ existing.setDrawerWidth === state.setDrawerWidth
+ ) {
+ return prev;
+ }
+ return { ...prev, [state.id]: state };
+ });
+ }, []);
+
+ // Convert states record to array
+ const statesArray = useMemo(
+ () => Object.values(drawerStates),
+ [drawerStates],
+ );
+
+ // Get active drawer - find the open drawer with highest priority
+ const activeDrawer = useMemo(() => {
+ return statesArray
+ .filter(state => state.isDrawerOpen)
+ .map(state => {
+ const content = drawerContents.find(c => c.id === state.id);
+ if (!content) return null;
+ return { ...state, ...content };
+ })
+ .filter(Boolean)
+ .sort((a, b) => (b?.priority ?? -1) - (a?.priority ?? -1))[0];
+ }, [statesArray, drawerContents]);
+
+ // Manage CSS classes and variables for layout adjustments
+ useEffect(() => {
+ if (activeDrawer) {
+ const className = `docked-drawer-open`;
+ const cssVar = `--docked-drawer-width`;
+
+ document.body.classList.add(className);
+ document.body.style.setProperty(cssVar, `${activeDrawer.drawerWidth}px`);
+
+ return () => {
+ document.body.classList.remove(className);
+ document.body.style.removeProperty(cssVar);
+ };
+ }
+ return undefined;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeDrawer?.id, activeDrawer?.drawerWidth]);
+
+ // Wrapper to handle the width change callback type
+ const handleWidthChange = useCallback(
+ (width: number) => {
+ activeDrawer?.setDrawerWidth(width);
+ },
+ [activeDrawer],
+ );
+
+ return (
+ <>
+ {/* Render all state exposers - they return null but report their state */}
+ {stateExposers.map(({ Component }, index) => (
+
+ ))}
+
+ {/* Render the active drawer */}
+ {activeDrawer && (
+
+
+
+ )}
+ >
+ );
+};
diff --git a/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx b/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx
new file mode 100644
index 0000000000..f5149db4b3
--- /dev/null
+++ b/workspaces/quickstart/packages/app/src/components/Root/CustomDrawer.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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 Box from '@mui/material/Box';
+import Drawer from '@mui/material/Drawer';
+
+import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme';
+
+export type CustomDrawerProps = {
+ children: React.ReactNode;
+ minWidth?: number;
+ maxWidth?: number;
+ initialWidth?: number;
+ isDrawerOpen: boolean;
+ drawerWidth?: number;
+ onWidthChange?: (width: number) => void;
+ [key: string]: any;
+};
+
+export const CustomDrawer = (props: CustomDrawerProps) => {
+ const {
+ children,
+ minWidth = 400,
+ maxWidth = 800,
+ initialWidth = 400,
+ isDrawerOpen,
+ drawerWidth,
+ onWidthChange,
+ ...drawerProps
+ } = props;
+
+ // Ensure anchor is always 'right' and not overridden by drawerProps
+ const { anchor: _, ...restDrawerProps } = drawerProps;
+
+ return (
+ {
+ const themeConfig = theme as ThemeConfig;
+ return (
+ themeConfig.palette?.rhdh?.general?.sidebarBackgroundColor ||
+ theme.palette.background.paper
+ );
+ },
+ justifyContent: 'space-between',
+ },
+ // Only apply header offset when global header exists
+ 'body:has(#global-header) &': {
+ '& .v5-MuiDrawer-paper': {
+ top: '64px !important',
+ height: 'calc(100vh - 64px) !important',
+ },
+ },
+ }}
+ variant="persistent"
+ open={isDrawerOpen}
+ >
+ {children}
+
+ );
+};
diff --git a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx
index 9731ec6cc9..9d8685f4eb 100644
--- a/workspaces/quickstart/packages/app/src/components/Root/Root.tsx
+++ b/workspaces/quickstart/packages/app/src/components/Root/Root.tsx
@@ -47,9 +47,14 @@ import {
GlobalHeaderComponent,
} from '@red-hat-developer-hub/backstage-plugin-global-header';
import Box from '@mui/material/Box';
-import { QuickstartDrawerProvider } from '@red-hat-developer-hub/backstage-plugin-quickstart';
+import {
+ QuickstartDrawerProvider,
+ QuickstartDrawerContent,
+ QuickstartDrawerStateExposer,
+} from '@red-hat-developer-hub/backstage-plugin-quickstart';
import { QuickstartSidebarItem } from './QuickstartSidebarItem';
import { Administration } from '@backstage-community/plugin-rbac';
+import { ApplicationDrawer } from './ApplicationDrawer';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -88,9 +93,9 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
// This code exists similarly in RHDH:
// https://github.com/redhat-developer/rhdh/blob/main/packages/app/src/components/Root/Root.tsx#L159-L165
// https://github.com/redhat-developer/rhdh/blob/main/packages/app/src/components/ErrorPages/ErrorPage.tsx#L54-L59
- 'body.quickstart-drawer-open #sidebar&': {
+ 'body.docked-drawer-open #sidebar&': {
"> div > main[class*='BackstagePage-root']": {
- marginRight: 'calc(var(--quickstart-drawer-width, 500px) + 1.5em)',
+ marginRight: 'calc(var(--docked-drawer-width, 500px) + 1.5em)',
transition: 'margin-right 0.3s ease',
},
},
@@ -141,6 +146,16 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
{children}
+
diff --git a/workspaces/quickstart/plugins/quickstart/dev/index.tsx b/workspaces/quickstart/plugins/quickstart/dev/index.tsx
index 1b36d1335f..6e4f575190 100644
--- a/workspaces/quickstart/plugins/quickstart/dev/index.tsx
+++ b/workspaces/quickstart/plugins/quickstart/dev/index.tsx
@@ -25,6 +25,8 @@ import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Button from '@mui/material/Button';
import { getAllThemes } from '@red-hat-developer-hub/backstage-plugin-theme';
+import { QuickstartDrawerContent } from '../src/components/QuickstartDrawerContent';
+import { DrawerComponent } from '../src/components/DrawerComponent';
const QuickstartTestPageContent = () => {
const { openDrawer, closeDrawer, isDrawerOpen } =
@@ -99,6 +101,9 @@ const QuickstartTestPageContent = () => {
const QuickstartTestPage = () => (
+
+
+
);
diff --git a/workspaces/quickstart/plugins/quickstart/report.api.md b/workspaces/quickstart/plugins/quickstart/report.api.md
index 90eab2665a..7d224a2ff7 100644
--- a/workspaces/quickstart/plugins/quickstart/report.api.md
+++ b/workspaces/quickstart/plugins/quickstart/report.api.md
@@ -12,6 +12,19 @@ import { PropsWithChildren } from 'react';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import { TranslationResource } from '@backstage/core-plugin-api/alpha';
+// @public
+export type DrawerPartialState = {
+ id: string;
+ isDrawerOpen: boolean;
+ drawerWidth: number;
+ setDrawerWidth: (width: number) => void;
+};
+
+// @public
+export type DrawerStateExposerProps = {
+ onStateChange: (state: DrawerPartialState) => void;
+};
+
// @public
export const filterQuickstartItemsByRole: (
items: QuickstartItemData[],
@@ -32,6 +45,9 @@ export interface QuickstartButtonProps {
title?: string;
}
+// @public
+export const QuickstartDrawerContent: () => JSX_2.Element | null;
+
// @public
export interface QuickstartDrawerContextType {
closeDrawer: () => void;
@@ -49,6 +65,11 @@ export const QuickstartDrawerProvider: ({
children,
}: PropsWithChildren) => JSX_2.Element;
+// @public
+export const QuickstartDrawerStateExposer: ({
+ onStateChange,
+}: DrawerStateExposerProps) => null;
+
// @public
export interface QuickstartItemCtaData {
link: string;
diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx b/workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx
similarity index 83%
rename from workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx
rename to workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx
index 76856b34ee..c14c9bcf47 100644
--- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawer.tsx
+++ b/workspaces/quickstart/plugins/quickstart/src/components/DrawerComponent.tsx
@@ -14,18 +14,17 @@
* limitations under the License.
*/
+import { PropsWithChildren, useMemo } from 'react';
import Drawer from '@mui/material/Drawer';
import { ThemeConfig } from '@red-hat-developer-hub/backstage-plugin-theme';
import { configApiRef, useApiHolder } from '@backstage/core-plugin-api';
-import { Quickstart } from './Quickstart';
import { useQuickstartDrawerContext } from '../hooks/useQuickstartDrawerContext';
import { QuickstartItemData } from '../types';
import { filterQuickstartItemsByRole } from '../utils';
// Role is now provided through context to avoid re-fetching on drawer open/close
-import { useMemo } from 'react';
-export const QuickstartDrawer = () => {
- const { isDrawerOpen, closeDrawer, drawerWidth, userRole, roleLoading } =
+export const DrawerComponent = ({ children }: PropsWithChildren) => {
+ const { isDrawerOpen, drawerWidth, userRole, roleLoading } =
useQuickstartDrawerContext();
const apiHolder = useApiHolder();
@@ -43,11 +42,6 @@ export const QuickstartDrawer = () => {
: [];
}, [roleLoading, userRole, quickstartItems]);
- // Only expose items to the body when drawer is open to avoid re-renders during close
- const filteredItems = useMemo(() => {
- return isDrawerOpen ? eligibleItems : [];
- }, [isDrawerOpen, eligibleItems]);
-
// No auto-open logic here; the provider initializes per user (visited/open)
// If no quickstart items are configured at all, don't render the drawer to avoid reserving space
@@ -87,11 +81,7 @@ export const QuickstartDrawer = () => {
anchor="right"
open={isDrawerOpen}
>
-
+ {children}
);
};
diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx
new file mode 100644
index 0000000000..c5268acec0
--- /dev/null
+++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerContent.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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 { useMemo } from 'react';
+import { configApiRef, useApiHolder } from '@backstage/core-plugin-api';
+import { Quickstart } from './Quickstart';
+import { useQuickstartDrawerContext } from '../hooks/useQuickstartDrawerContext';
+import { QuickstartItemData } from '../types';
+import { filterQuickstartItemsByRole } from '../utils';
+
+export const QuickstartDrawerContent = () => {
+ const { isDrawerOpen, closeDrawer, userRole, roleLoading } =
+ useQuickstartDrawerContext();
+
+ const apiHolder = useApiHolder();
+ const config = apiHolder.get(configApiRef);
+ const quickstartItems: QuickstartItemData[] = useMemo(() => {
+ return config?.has('app.quickstart')
+ ? (config.get('app.quickstart') as QuickstartItemData[])
+ : [];
+ }, [config]);
+
+ // Items available to the user based on role from context
+ const eligibleItems = useMemo(() => {
+ return !roleLoading && userRole
+ ? filterQuickstartItemsByRole(quickstartItems, userRole)
+ : [];
+ }, [roleLoading, userRole, quickstartItems]);
+
+ // Only expose items to the body when drawer is open to avoid re-renders during close
+ const filteredItems = useMemo(() => {
+ return isDrawerOpen ? eligibleItems : [];
+ }, [isDrawerOpen, eligibleItems]);
+
+ // No auto-open logic here; the provider initializes per user (visited/open)
+
+ // If no quickstart items are configured at all, don't render the drawer to avoid reserving space
+ if (quickstartItems.length === 0) {
+ return null;
+ }
+
+ // If there are no items for the user, hide the drawer entirely
+ if (!roleLoading && eligibleItems.length === 0) {
+ return null;
+ }
+
+ // No role-fetching or filtering here when the drawer is closed
+
+ return (
+
+ );
+};
diff --git a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx
index 1db449e068..842f1edb34 100644
--- a/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx
+++ b/workspaces/quickstart/plugins/quickstart/src/components/QuickstartDrawerProvider.tsx
@@ -24,7 +24,6 @@ import Snackbar from '@mui/material/Snackbar';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { QuickstartDrawerContext } from './QuickstartDrawerContext';
-import { QuickstartDrawer } from './QuickstartDrawer';
import { QuickstartItemData } from '../types';
import { filterQuickstartItemsByRole } from '../utils';
import { useQuickstartRole } from '../hooks/useQuickstartRole';
@@ -45,25 +44,6 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => {
// Determine role once at provider level to avoid re-fetching on drawer open/close
const { isLoading: roleLoading, userRole } = useQuickstartRole();
- // Single useEffect - sets class on document.body
- useEffect(() => {
- if (isDrawerOpen) {
- document.body.classList.add('quickstart-drawer-open');
- document.body.style.setProperty(
- '--quickstart-drawer-width',
- `${drawerWidth}px`,
- );
- } else {
- document.body.classList.remove('quickstart-drawer-open');
- document.body.style.removeProperty('--quickstart-drawer-width');
- }
-
- return () => {
- document.body.classList.remove('quickstart-drawer-open');
- document.body.style.removeProperty('--quickstart-drawer-width');
- };
- }, [isDrawerOpen, drawerWidth]);
-
// Resolve the current user's identity to scope localStorage keys per user
useEffect(() => {
let cancelled = false;
@@ -201,7 +181,6 @@ export const QuickstartDrawerProvider = ({ children }: PropsWithChildren) => {
}}
>
{children}
-
void;
+};
+
+/**
+ * Props for drawer state exposer components
+ *
+ * @public
+ */
+export type DrawerStateExposerProps = {
+ /**
+ * Callback called whenever the drawer state changes
+ */
+ onStateChange: (state: DrawerPartialState) => void;
+};
+
+/**
+ * This exposes Quickstart Drawer's partial context to the ApplicationDrawer
+ *
+ * It reads the QuickstartDrawerContext and calls the onStateChange callback with the
+ * partial state (id, isDrawerOpen, drawerWidth, setDrawerWidth).
+ *
+ * @public
+ */
+export const QuickstartDrawerStateExposer = ({
+ onStateChange,
+}: DrawerStateExposerProps) => {
+ const { isDrawerOpen, drawerWidth, setDrawerWidth } =
+ useQuickstartDrawerContext();
+
+ useEffect(() => {
+ onStateChange({
+ id: 'quickstart',
+ isDrawerOpen,
+ drawerWidth,
+ setDrawerWidth,
+ });
+ }, [isDrawerOpen, drawerWidth, onStateChange, setDrawerWidth]);
+
+ return null;
+};
diff --git a/workspaces/quickstart/plugins/quickstart/src/index.ts b/workspaces/quickstart/plugins/quickstart/src/index.ts
index a9e3cfb234..8ea0dd80fb 100644
--- a/workspaces/quickstart/plugins/quickstart/src/index.ts
+++ b/workspaces/quickstart/plugins/quickstart/src/index.ts
@@ -26,6 +26,11 @@ export * from './plugin';
export { useQuickstartDrawerContext } from './hooks/useQuickstartDrawerContext';
export type { QuickstartDrawerContextType } from './components/QuickstartDrawerContext';
+export { QuickstartDrawerStateExposer } from './components/QuickstartDrawerStateExposer';
+export type {
+ DrawerStateExposerProps,
+ DrawerPartialState,
+} from './components/QuickstartDrawerStateExposer';
/**
* @public
*/
diff --git a/workspaces/quickstart/plugins/quickstart/src/plugin.ts b/workspaces/quickstart/plugins/quickstart/src/plugin.ts
index 5fda53cc00..2459bf7b2d 100644
--- a/workspaces/quickstart/plugins/quickstart/src/plugin.ts
+++ b/workspaces/quickstart/plugins/quickstart/src/plugin.ts
@@ -55,6 +55,23 @@ export const QuickstartDrawerProvider: React.ComponentType =
}),
);
+/**
+ * Quickstart Drawer Content component extension
+ *
+ * @public
+ */
+export const QuickstartDrawerContent = quickstartPlugin.provide(
+ createComponentExtension({
+ name: 'QuickstartDrawerContent',
+ component: {
+ lazy: () =>
+ import('./components/QuickstartDrawerContent').then(
+ m => m.QuickstartDrawerContent,
+ ),
+ },
+ }),
+);
+
/**
* Quick start button for global header help dropdown
*
@@ -72,3 +89,20 @@ export const QuickstartButton: React.ComponentType =
},
}),
);
+
+/**
+ * Quickstart Drawer State Exposer exposes its drawer state
+ *
+ * @public
+ */
+export const QuickstartDrawerStateExposer = quickstartPlugin.provide(
+ createComponentExtension({
+ name: 'QuickstartDrawerStateExposer',
+ component: {
+ lazy: () =>
+ import('./components/QuickstartDrawerStateExposer').then(
+ m => m.QuickstartDrawerStateExposer,
+ ),
+ },
+ }),
+);