Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
---

Add AI notebooks UI, hooks, and backend support for listing/renaming/deleting sessions.
23 changes: 23 additions & 0 deletions workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ readonly "prompts.rhdh.title": string;
readonly "prompts.rhdh.message": string;
readonly "page.title": string;
readonly "page.subtitle": string;
readonly "tabs.ariaLabel": string;
readonly "tabs.chat": string;
readonly "tabs.notebooks": string;
readonly "tabs.notebooks.empty": string;
readonly "notebooks.title": string;
readonly "notebooks.empty.title": string;
readonly "notebooks.empty.description": string;
readonly "notebooks.empty.action": string;
readonly "notebooks.documents": string;
readonly "notebooks.actions.rename": string;
readonly "notebooks.actions.delete": string;
readonly "notebooks.rename.title": string;
readonly "notebooks.rename.description": string;
readonly "notebooks.rename.label": string;
readonly "notebooks.rename.placeholder": string;
readonly "notebooks.rename.action": string;
readonly "notebooks.delete.title": string;
readonly "notebooks.delete.message": string;
readonly "notebooks.delete.action": string;
readonly "notebooks.updated.today": string;
readonly "notebooks.updated.yesterday": string;
readonly "notebooks.updated.days": string;
readonly "notebooks.updated.on": string;
readonly "conversation.delete.confirm.title": string;
readonly "conversation.delete.confirm.message": string;
readonly "conversation.delete.confirm.action": string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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 { ConfigApi, FetchApi } from '@backstage/core-plugin-api';

import { NotebookSession } from '../types';
import { NotebooksAPI } from './notebooksApi';

/**
* @public
* AI Notebooks API client options
*/
export type NotebooksOptions = {
configApi: ConfigApi;
fetchApi: FetchApi;
};

/**
* @public
* AI Notebooks API client implementation
*/
export class NotebooksApiClient implements NotebooksAPI {
private readonly configApi: ConfigApi;
private readonly fetchApi: FetchApi;

constructor(options: NotebooksOptions) {
this.configApi = options.configApi;
this.fetchApi = options.fetchApi;
}

async getBaseUrl() {
return `${this.configApi.getString('backend.baseUrl')}/api/lightspeed/ai-notebooks`;
}

private async fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await this.fetchApi.fetch(url, {
headers: {
'Content-Type': 'application/json',
},
...init,
});

if (!response.ok) {
let errorMessage = `failed to fetch data, status ${response.status}: ${response.statusText}`;
try {
const errorText = await response.text();
if (errorText) {
try {
const errorBody = JSON.parse(errorText);
if (errorBody?.error) {
errorMessage = errorBody.error;
}
} catch {
errorMessage = errorText;
}
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
}
throw new Error(errorMessage);
}

const text = await response.text();
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}

async listSessions() {
const baseUrl = await this.getBaseUrl();
const response = await this.fetchJson<{ sessions?: NotebookSession[] }>(
`${baseUrl}/v1/sessions`,
);
return response?.sessions ?? [];
}

async renameSession(sessionId: string, name: string) {
const baseUrl = await this.getBaseUrl();
await this.fetchJson(
`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'PUT',
body: JSON.stringify({ name }),
},
);
}

async deleteSession(sessionId: string) {
const baseUrl = await this.getBaseUrl();
await this.fetchJson(
`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'DELETE',
},
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';

import { NotebookSession } from '../types';

/**
* @public
* AI Notebooks API
*/
export type NotebooksAPI = {
listSessions: () => Promise<NotebookSession[]>;
renameSession: (sessionId: string, name: string) => Promise<void>;
deleteSession: (sessionId: string) => Promise<void>;
};

/**
* @public
* AI Notebooks API interface
*/
export const notebooksApiRef = createApiRef<NotebooksAPI>({
id: 'plugin.lightspeed.notebooks.service',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* 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 { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@mui/icons-material/Close';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';

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

const useStyles = makeStyles(theme => ({
dialogPaper: {
borderRadius: 16,
},
dialogTitle: {
padding: '16px 20px',
fontStyle: 'inherit',
},
dialogContent: {
paddingTop: 0,
paddingBottom: theme.spacing(5),
},
titleRow: {
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
},
titleText: {
fontWeight: 'bold',
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[700],
},
errorBox: {
maxWidth: 650,
marginLeft: theme.spacing(2.5),
marginRight: theme.spacing(2.5),
},
dialogActions: {
justifyContent: 'left',
padding: theme.spacing(2.5),
gap: theme.spacing(1),
},
deleteButton: {
textTransform: 'none',
borderRadius: 999,
},
cancelButton: {
textTransform: 'none',
borderRadius: 999,
},
}));

export const DeleteNotebookModal = ({
isOpen,
onClose,
sessionId,
name,
}: {
isOpen: boolean;
onClose: () => void;
sessionId: string;
name: string;
}) => {
const classes = useStyles();
const { t } = useTranslation();
const { mutateAsync: deleteNotebook, isError, error } = useDeleteNotebook();

const handleDelete = async () => {
try {
await deleteNotebook(sessionId);
onClose();
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
}
};

return (
<Dialog
open={isOpen}
onClose={onClose}
aria-labelledby="delete-notebook-modal"
aria-describedby="delete-notebook-modal-body"
fullWidth
PaperProps={{
className: classes.dialogPaper,
}}
>
<DialogTitle className={classes.dialogTitle}>
<Box className={classes.titleRow}>
<Typography component="span" className={classes.titleText}>
{t('notebooks.delete.title').replace('{{name}}', name)}
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
title={t('common.close')}
size="large"
className={classes.closeButton}
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent
id="delete-notebook-modal-body"
className={classes.dialogContent}
>
<Typography variant="body2">{t('notebooks.delete.message')}</Typography>
</DialogContent>
{isError && (
<Box className={classes.errorBox}>
<Alert severity="error">{String(error)}</Alert>
</Box>
)}
<DialogActions className={classes.dialogActions}>
<Button
variant="contained"
color="error"
className={classes.deleteButton}
onClick={handleDelete}
>
{t('notebooks.delete.action')}
</Button>
Comment on lines +141 to +149

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Delete spammable 🐞 Bug ⛯ Reliability

DeleteNotebookModal does not disable the Delete button during an in-flight mutation, allowing
multiple rapid clicks that can submit multiple DELETE requests.
Agent Prompt
### Issue description
The delete action can be triggered multiple times because the Delete button remains enabled while the async mutation is running.

### Issue Context
Conversation deletion already prevents this by disabling the confirm button using `isPending`; notebooks deletion should follow the same pattern.

### Fix Focus Areas
- workspaces/lightspeed/plugins/lightspeed/src/components/DeleteNotebookModal.tsx[89-158]
- workspaces/lightspeed/plugins/lightspeed/src/hooks/useDeleteNotebook.ts[27-42]
- workspaces/lightspeed/plugins/lightspeed/src/components/DeleteModal.tsx[43-48]
- workspaces/lightspeed/plugins/lightspeed/src/components/DeleteModal.tsx[107-114]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

<Button
key="cancel"
variant="outlined"
className={classes.cancelButton}
onClick={onClose}
>
{t('common.cancel')}
</Button>
</DialogActions>
</Dialog>
);
};
Loading
Loading