Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,18 @@ export const lightspeedTranslationRef: TranslationRef<
readonly 'permission.subject.plugin': string;
readonly 'permission.subject.notebooks': string;
readonly 'permission.notebooks.goBack': string;
readonly 'lcore.notConfigured.title': string;
readonly 'lcore.notConfigured.description': string;
readonly 'lcore.notConfigured.llamaStackDocs': string;
readonly 'lcore.notConfigured.backendDocs': string;
readonly 'lcore.loadError.title': string;
readonly 'lcore.loadError.description': string;
readonly 'footer.accuracy.label': string;
readonly 'common.cancel': string;
readonly 'common.close': string;
readonly 'common.readMore': string;
readonly 'common.retry': string;
readonly 'common.loading': string;
readonly 'common.noSearchResults': string;
readonly 'menu.newConversation': string;
readonly 'chatbox.header.title': string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ const useStyles = makeStyles(theme => ({
errorContainer: {
padding: theme.spacing(3),
},
drawerFileDropZone: {
gap: 0,
rowGap: 0,
columnGap: 0,
'--pf-v6-c-multiple-file-upload--Gap': '0',
'--pf-v5-c-multiple-file-upload--Gap': '0',
},
headerMenu: {
// align hamburger icon with title
'& .pf-v6-c-button': {
Expand Down Expand Up @@ -1529,6 +1536,7 @@ export const LightspeedChat = ({
}
drawerContent={
<FileDropZone
className={classes.drawerFileDropZone}
onFileDrop={(e, data) => handleAttach(data, e)}
displayMode={ChatbotDisplayMode.embedded}
infoText={t('chatbox.fileUpload.infoText')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import { useTranslation } from '../hooks/useTranslation';
import queryClient from '../utils/queryClient';
import FileAttachmentContextProvider from './AttachmentContext';
import { LightspeedChat } from './LightSpeedChat';
import {
LcoreNotConfiguredEmptyState,
LightspeedChatModelsLoading,
ModelsLoadErrorEmptyState,
} from './LightspeedChatModelsState';
import PermissionRequiredState from './PermissionRequiredState';

const THEME_DARK = 'dark';
Expand All @@ -48,7 +53,12 @@ const LightspeedChatContainerInner = () => {

const identityApi = useApi(identityApiRef);

const { data: models } = useAllModels();
const {
data: models,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useAllModels();

const { allowed: hasViewAccess, loading } = useLightspeedViewPermission();

Expand Down Expand Up @@ -137,7 +147,10 @@ const LightspeedChatContainerInner = () => {
}, [selectedModel, selectedProvider]);

if (loading) {
return null;
// Never return null inside the overlay modal: PatternFly's focus-trap requires at least
// one tabbable node (e.g. after removing the modal close button). Locale switches can
// briefly re-enter this loading state.
return <LightspeedChatModelsLoading />;
}

if (!hasViewAccess) {
Expand All @@ -159,6 +172,21 @@ const LightspeedChatContainerInner = () => {
);
}

if (modelsLoading) {
return <LightspeedChatModelsLoading />;
}

// TanStack Query can keep the last successful `data` while `isError` is true after a
// failed refetch. Prefer showing chat when we still have LLM rows; only use the full-page
// error state when there is nothing usable to render.
if (modelsError && modelsItems.length === 0) {
return <ModelsLoadErrorEmptyState onRetry={() => refetchModels()} />;
}

if (modelsItems.length === 0) {
return <LcoreNotConfiguredEmptyState />;
}

return (
<FileAttachmentContextProvider>
<LightspeedChat
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/*
* 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,
Button,
CircularProgress,
Link,
Typography,
} from '@material-ui/core';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';

import { useTranslation } from '../hooks/useTranslation';

const LLAMA_STACK_DOCS_URL = 'https://github.com/llamastack/llama-stack';
const LIGHTSPEED_BACKEND_README_URL =
'https://github.com/redhat-developer/rhdh-plugins/blob/main/workspaces/lightspeed/plugins/lightspeed-backend/README.md';

const useStyles = makeStyles(theme =>
createStyles({
root: {
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
width: '100%',
maxWidth: '100%',
minWidth: 0,
minHeight: '100%',
height: '100%',
flex: '1 1 auto',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4, 2),
backgroundColor: theme.palette.background.default,
},
panel: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
width: '100%',
maxWidth: 440,
gap: theme.spacing(2),
},
emptyStateIcon: {
fontSize: 64,
color: theme.palette.text.secondary,
},
errorIcon: {
fontSize: 64,
color: theme.palette.warning.main,
},
description: {
lineHeight: 1.5,
color: theme.palette.text.secondary,
},
actions: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(1.5),
marginTop: theme.spacing(1),
},
backendLink: {
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.5),
fontSize: theme.typography.body1.fontSize,
fontWeight: 500,
},
}),
);

/**
* Shown while the models list is loading for an authorized user.
*/
export const LightspeedChatModelsLoading = () => {
const classes = useStyles();
const { t } = useTranslation();
return (
<div
className={classes.root}
data-testid="lightspeed-models-loading"
role="status"
aria-busy="true"
aria-label={t('common.loading')}
>

Check warning on line 103 in workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatModelsState.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <output> instead of the "status" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ2Xq1Tj1EyDTCZygdXT&open=AZ2Xq1Tj1EyDTCZygdXT&pullRequest=2781
<CircularProgress aria-hidden />
</div>
);
};

/**
* Shown when LCORE / Llama Stack is up but no LLM models are registered.
*/
export const LcoreNotConfiguredEmptyState = () => {
const classes = useStyles();
const { t } = useTranslation();

return (
<div className={classes.root} data-testid="lightspeed-lcore-not-configured">
<Box
className={classes.panel}
component="section"
aria-labelledby="lightspeed-lcore-empty-title"
>
<SmartToyOutlinedIcon
className={classes.emptyStateIcon}
aria-hidden
focusable={false}
/>
<Typography
id="lightspeed-lcore-empty-title"
variant="h5"
component="h2"
>
{t('lcore.notConfigured.title')}
</Typography>
<Typography
variant="body1"
component="p"
className={classes.description}
>
{t('lcore.notConfigured.description')}
</Typography>
<Box className={classes.actions}>
<Button
variant="contained"
color="primary"
target="_blank"
rel="noopener noreferrer"
href={LLAMA_STACK_DOCS_URL}
>
{t('lcore.notConfigured.llamaStackDocs')} &nbsp;{' '}
<OpenInNewIcon fontSize="small" aria-hidden />
</Button>
<Link
className={classes.backendLink}
component="a"
color="primary"
href={LIGHTSPEED_BACKEND_README_URL}
target="_blank"
rel="noopener noreferrer"
>
{t('lcore.notConfigured.backendDocs')}
<OpenInNewIcon fontSize="small" aria-hidden />
</Link>
</Box>
</Box>
</div>
);
};

type ModelsLoadErrorEmptyStateProps = {
onRetry: () => void;
};

/**
* Shown when the models API fails (distinct from “no models configured”).
*/
export const ModelsLoadErrorEmptyState = ({
onRetry,
}: ModelsLoadErrorEmptyStateProps) => {
const classes = useStyles();
const { t } = useTranslation();

return (
<div className={classes.root} data-testid="lightspeed-models-load-error">
<Box
className={classes.panel}
component="section"
aria-labelledby="lightspeed-models-error-title"
>
<ErrorOutlineIcon
className={classes.errorIcon}
aria-hidden
focusable={false}
/>
<Typography
id="lightspeed-models-error-title"
variant="h5"
component="h2"
>
{t('lcore.loadError.title')}
</Typography>
<Typography
variant="body1"
component="p"
className={classes.description}
>
{t('lcore.loadError.description')}
</Typography>
<Box className={classes.actions}>
<Button variant="contained" color="primary" onClick={() => onRetry()}>
{t('common.retry')}
</Button>
</Box>
</Box>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => {
<ChatbotModal
isOpen
displayMode={contextValue.displayMode}
onClose={closeChatbot}
disableFocusTrap
onEscapePress={() => closeChatbot()}
ouiaId="LightspeedChatbotModal"
aria-labelledby="lightspeed-chatpopup-modal"
className={classes.chatbotModal}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ jest.mock('@patternfly/chatbot', () => {
ChatbotModal: ({
children,
onClose,
onEscapePress,
displayMode,
className,
ouiaId,
'aria-labelledby': ariaLabelledBy,
}: {
children: React.ReactNode;
onClose: () => void;
onClose?: () => void;
onEscapePress?: () => void;
displayMode: ChatbotDisplayMode;
className?: string;
ouiaId?: string;
Expand All @@ -54,9 +56,20 @@ jest.mock('@patternfly/chatbot', () => {
data-display-mode={displayMode}
className={className}
>
<button type="button" data-testid="modal-close" onClick={onClose}>
Close
</button>
{onClose ? (
<button type="button" data-testid="modal-close" onClick={onClose}>
Close
</button>
) : null}
{onEscapePress ? (
<button
type="button"
data-testid="modal-escape-close"
onClick={() => onEscapePress()}
>
Escape close
</button>
) : null}
{children}
</div>
),
Expand Down Expand Up @@ -172,7 +185,7 @@ describe('LightspeedDrawerProvider', () => {
expect(screen.getByTestId('lightspeed-chat-container')).toBeInTheDocument();
});

it('wires ChatbotModal onClose to closeChatbot from the hook', async () => {
it('wires ChatbotModal onEscapePress to closeChatbot from the hook', async () => {
const user = userEvent.setup();
const closeChatbot = jest.fn();
mockUseLightspeedProviderState.mockReturnValue({
Expand All @@ -187,7 +200,7 @@ describe('LightspeedDrawerProvider', () => {
</LightspeedDrawerProvider>,
);

await user.click(screen.getByTestId('modal-close'));
await user.click(screen.getByTestId('modal-escape-close'));
expect(closeChatbot).toHaveBeenCalledTimes(1);
});
});
Loading
Loading