From 29e2202ca34ed178d49843c2c5176af80148389d Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Mon, 30 Mar 2026 20:05:20 +0530 Subject: [PATCH 01/14] feat(lightspeed): create notebook flow Signed-off-by: its-mitesh-kumar --- .../lightspeed/src/api/NotebooksApiClient.ts | 130 ++++++++-- .../lightspeed/src/api/notebooksApi.ts | 24 +- .../src/components/LightSpeedChat.tsx | 45 +++- .../components/notebooks/DocumentSidebar.tsx | 139 +++++++++++ .../src/components/notebooks/NotebookView.tsx | 230 ++++++++++++++++++ .../src/components/notebooks/NotebooksTab.tsx | 9 +- .../notebooks/SidebarCollapseIcon.tsx | 61 +++++ .../notebooks/UploadResourceScreen.tsx | 79 ++++++ .../plugins/lightspeed/src/const.ts | 28 +++ .../src/hooks/notebooks/useCreateNotebook.ts | 49 ++++ .../lightspeed/src/translations/ref.ts | 12 + .../plugins/lightspeed/src/types.ts | 72 ++++++ 12 files changed, 854 insertions(+), 24 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/SidebarCollapseIcon.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/UploadResourceScreen.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebook.ts diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts index 8400518676b..d660f99223b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts @@ -16,7 +16,13 @@ import { ConfigApi, FetchApi } from '@backstage/core-plugin-api'; -import { NotebookSession } from '../types'; +import { + DocumentListResponse, + DocumentStatus, + NotebookSession, + SessionResponse, + UploadDocumentResponse, +} from '../types'; import { NotebooksAPI } from './notebooksApi'; /** @@ -45,6 +51,27 @@ export class NotebooksApiClient implements NotebooksAPI { return `${this.configApi.getString('backend.baseUrl')}/api/lightspeed/ai-notebooks`; } + private async handleResponseError(response: Response): Promise { + 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); + } + return errorMessage; + } + private async fetchJson(url: string, init?: RequestInit): Promise { const response = await this.fetchApi.fetch(url, { headers: { @@ -54,24 +81,7 @@ export class NotebooksApiClient implements NotebooksAPI { }); 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); + throw new Error(await this.handleResponseError(response)); } const text = await response.text(); @@ -81,6 +91,42 @@ export class NotebooksApiClient implements NotebooksAPI { return JSON.parse(text) as T; } + private async fetchFormData( + url: string, + formData: FormData, + method: string = 'PUT', + ): Promise { + const response = await this.fetchApi.fetch(url, { + method, + body: formData, + }); + + if (!response.ok && response.status !== 202) { + throw new Error(await this.handleResponseError(response)); + } + + const text = await response.text(); + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; + } + + async createSession(name: string, description?: string) { + const baseUrl = await this.getBaseUrl(); + const response = await this.fetchJson( + `${baseUrl}/v1/sessions`, + { + method: 'POST', + body: JSON.stringify({ name, description }), + }, + ); + if (!response.session) { + throw new Error(response.error ?? 'Failed to create session'); + } + return response.session; + } + async listSessions() { const baseUrl = await this.getBaseUrl(); const response = await this.fetchJson<{ sessions?: NotebookSession[] }>( @@ -109,4 +155,50 @@ export class NotebooksApiClient implements NotebooksAPI { }, ); } + + async uploadDocument( + sessionId: string, + file: File, + fileType: string, + title: string, + newTitle?: string, + ) { + const baseUrl = await this.getBaseUrl(); + const formData = new FormData(); + formData.append('file', file); + formData.append('fileType', fileType); + formData.append('title', title); + if (newTitle) { + formData.append('newTitle', newTitle); + } + return this.fetchFormData( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents`, + formData, + ); + } + + async listDocuments(sessionId: string) { + const baseUrl = await this.getBaseUrl(); + const response = await this.fetchJson( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents`, + ); + return response?.documents ?? []; + } + + async deleteDocument(sessionId: string, documentId: string) { + const baseUrl = await this.getBaseUrl(); + await this.fetchJson( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}`, + { + method: 'DELETE', + }, + ); + } + + async getDocumentStatus(sessionId: string, documentId: string) { + const baseUrl = await this.getBaseUrl(); + return this.fetchJson( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}/status`, + ); + } } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts index fa1b9d455a8..7ba87ccf194 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts @@ -16,16 +16,38 @@ import { createApiRef, type ApiRef } from '@backstage/core-plugin-api'; -import { NotebookSession } from '../types'; +import { + DocumentStatus, + NotebookSession, + SessionDocument, + UploadDocumentResponse, +} from '../types'; /** * @public * AI Notebooks API */ export type NotebooksAPI = { + createSession: ( + name: string, + description?: string, + ) => Promise; listSessions: () => Promise; renameSession: (sessionId: string, name: string) => Promise; deleteSession: (sessionId: string) => Promise; + uploadDocument: ( + sessionId: string, + file: File, + fileType: string, + title: string, + newTitle?: string, + ) => Promise; + listDocuments: (sessionId: string) => Promise; + deleteDocument: (sessionId: string, documentId: string) => Promise; + getDocumentStatus: ( + sessionId: string, + documentId: string, + ) => Promise; }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index d4d3840b1da..95e1563f3c1 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -72,7 +72,11 @@ import { } from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; -import { supportedFileTypes, TEMP_CONVERSATION_ID } from '../const'; +import { + supportedFileTypes, + TEMP_CONVERSATION_ID, + UNTITLED_NOTEBOOK_NAME, +} from '../const'; import { useBackstageUserIdentity, useConversationMessages, @@ -85,11 +89,12 @@ import { usePinnedChatsSettings, useSortSettings, } from '../hooks'; +import { useCreateNotebook } from '../hooks/notebooks/useCreateNotebook'; import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; import { useWelcomePrompts } from '../hooks/useWelcomePrompts'; -import { ConversationSummary } from '../types'; +import { ConversationSummary, NotebookSession } from '../types'; import { getAttachments } from '../utils/attachment-utils'; import { getCategorizeMessages, @@ -104,6 +109,7 @@ import { LightspeedChatBox } from './LightspeedChatBox'; import { LightspeedChatBoxHeader } from './LightspeedChatBoxHeader'; import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; import { NotebooksTab } from './notebooks/NotebooksTab'; +import { NotebookView } from './notebooks/NotebookView'; import { RenameNotebookModal } from './notebooks/RenameNotebookModal'; import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; @@ -372,9 +378,13 @@ export const LightspeedChat = ({ ); const [renameNotebookId, setRenameNotebookId] = useState(null); const [deleteNotebookId, setDeleteNotebookId] = useState(null); + const [activeNotebook, setActiveNotebook] = useState( + null, + ); const [notebookAlerts, setNotebookAlerts] = useState[]>( [], ); + const createNotebookMutation = useCreateNotebook(); const [conversationId, setConversationId] = useState(''); const [newChatCreated, setNewChatCreated] = useState(false); const [isSendButtonDisabled, setIsSendButtonDisabled] = @@ -412,6 +422,21 @@ export const LightspeedChat = ({ } }; + const handleCreateNotebook = useCallback(() => { + createNotebookMutation.mutate( + { name: UNTITLED_NOTEBOOK_NAME }, + { + onSuccess: (session: NotebookSession) => { + setActiveNotebook(session); + }, + }, + ); + }, [createNotebookMutation]); + + const handleCloseNotebook = useCallback(() => { + setActiveNotebook(null); + }, []); + const handleNotebookDeleted = () => { const key = Date.now(); setNotebookAlerts(prevAlerts => [ @@ -1226,7 +1251,20 @@ export const LightspeedChat = ({ )} {showNotebooksPanel && !notebooksPermissionLoading && - hasNotebooksAccess && ( + hasNotebooksAccess && + activeNotebook && ( + {}} + onAddDocument={() => {}} + /> + )} + {showNotebooksPanel && + !notebooksPermissionLoading && + hasNotebooksAccess && + !activeNotebook && ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx new file mode 100644 index 00000000000..87064d2538d --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx @@ -0,0 +1,139 @@ +/* + * 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, Typography } from '@material-ui/core'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; + +import { useTranslation } from '../../hooks/useTranslation'; +import { SessionDocument } from '../../types'; +import { SidebarCollapseIcon } from './SidebarCollapseIcon'; + +const useStyles = makeStyles(theme => ({ + sidebar: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + padding: theme.spacing(2), + overflow: 'hidden', + }, + titleRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + gap: theme.spacing(1), + }, + title: { + fontWeight: 500, + fontSize: '1.25rem', + lineHeight: '2rem', + letterSpacing: '-0.25px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + flex: 1, + minWidth: 0, + }, + collapseButton: { + flexShrink: 0, + }, + documentsRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + documentCount: { + fontWeight: 700, + fontSize: '1.125rem', + lineHeight: '2rem', + }, + addButton: { + textTransform: 'none', + }, + documentsList: { + marginTop: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + overflowY: 'auto', + flex: 1, + }, +})); + +type DocumentSidebarProps = { + notebookName: string; + documents: SessionDocument[]; + collapsed: boolean; + onToggleCollapse: () => void; + onAddDocument: () => void; +}; + +export const DocumentSidebar = ({ + notebookName, + documents, + collapsed, + onToggleCollapse, + onAddDocument, +}: DocumentSidebarProps) => { + const classes = useStyles(); + const { t } = useTranslation(); + + if (collapsed) { + return null; + } + + return ( +
+
+ {notebookName} + + + +
+ +
+ + {t('notebook.view.documents.count', { + count: documents.length, + } as any)} + + +
+ + {documents.length > 0 && ( +
+ {/* Document list items will be rendered here when documents exist */} +
+ )} +
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx new file mode 100644 index 00000000000..ea3ded71caa --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -0,0 +1,230 @@ +/* + * 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 { useState } from 'react'; + +import { makeStyles } from '@material-ui/core'; +import { + ChatbotFooter, + ChatbotFootnote, + MessageBar, +} from '@patternfly/chatbot'; +import { + Alert, + Button, + Drawer, + DrawerContent, + DrawerContentBody, + DrawerPanelContent, + Tooltip, +} from '@patternfly/react-core'; +import { TimesIcon } from '@patternfly/react-icons'; + +import { UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { useTranslation } from '../../hooks/useTranslation'; +import { SessionDocument } from '../../types'; +import { DocumentSidebar } from './DocumentSidebar'; +import { AddCircleFilledIcon, SidebarExpandIcon } from './SidebarCollapseIcon'; +import { UploadResourceScreen } from './UploadResourceScreen'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + backgroundColor: 'var(--pf-t--global--background--color--primary--default)', + }, + drawerContainer: { + flex: 1, + minHeight: 0, + }, + expandStrip: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + paddingTop: theme.spacing(1.5), + gap: theme.spacing(1), + borderRight: '1px solid var(--pf-t--global--border--color--default)', + }, + addIconButton: { + padding: 0, + minWidth: 0, + lineHeight: 1, + }, + mainArea: { + display: 'flex', + flexDirection: 'row', + height: '100%', + minWidth: 0, + }, + topBar: { + display: 'flex', + justifyContent: 'flex-end', + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + }, + closeButton: { + textTransform: 'none', + }, + mainContent: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + }, + drawerContentBody: { + backgroundColor: + 'var(--pf-t--global--background--color--secondary--default)', + }, + contentColumn: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + }, + alertContainer: { + width: '100%', + maxWidth: 816, + margin: '0 auto', + padding: `0 ${theme.spacing(3)}px ${theme.spacing(1)}px`, + }, +})); + +type NotebookViewProps = { + notebookName?: string; + documents?: SessionDocument[]; + onClose: () => void; + onUploadClick: () => void; + onAddDocument: () => void; +}; + +export const NotebookView = ({ + notebookName = UNTITLED_NOTEBOOK_NAME, + documents = [], + onClose, + onUploadClick, + onAddDocument, +}: NotebookViewProps) => { + const classes = useStyles(); + const { t } = useTranslation(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + const hasDocuments = documents.length > 0; + + const panelContent = ( + + setSidebarCollapsed(prev => !prev)} + onAddDocument={onAddDocument} + /> + + ); + + return ( +
+ + + +
+ {sidebarCollapsed && ( +
+ + + + + + +
+ )} + +
+
+ +
+ +
+ {!hasDocuments && ( + + )} +
+ +
+ + {t('disclaimer.withoutValidation')} + +
+ + + {}} + placeholder={t('notebook.view.input.placeholder')} + /> + + +
+
+
+
+
+
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx index 6634069bb61..c5f2fd6434b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx @@ -33,6 +33,7 @@ type NotebooksTabProps = { setOpenNotebookMenuId: React.Dispatch>; onRename: (sessionId: string) => void; onDelete: (sessionId: string) => void; + onCreateNotebook: () => void; t: TranslationFunction; getDocumentsCount: (documentIds?: string[]) => number; }; @@ -45,6 +46,7 @@ export const NotebooksTab = ({ setOpenNotebookMenuId, onRename, onDelete, + onCreateNotebook, t, getDocumentsCount, }: NotebooksTabProps) => ( @@ -58,6 +60,7 @@ export const NotebooksTab = ({ variant="primary" className={classes.notebooksAction} icon={} + onClick={onCreateNotebook} > {t('notebooks.empty.action')} @@ -76,7 +79,11 @@ export const NotebooksTab = ({ > {t('notebooks.empty.description')} - diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/SidebarCollapseIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/SidebarCollapseIcon.tsx new file mode 100644 index 00000000000..117d22e556f --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/SidebarCollapseIcon.tsx @@ -0,0 +1,61 @@ +/* + * 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. + */ + +type IconProps = { + className?: string; +}; + +export const SidebarCollapseIcon = ({ className }: IconProps) => ( + + + +); + +export const SidebarExpandIcon = ({ className }: IconProps) => ( + + + +); + +export const AddCircleFilledIcon = ({ className }: IconProps) => ( + + + +); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/UploadResourceScreen.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/UploadResourceScreen.tsx new file mode 100644 index 00000000000..1d86d57779f --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/UploadResourceScreen.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 { makeStyles, Typography } from '@material-ui/core'; +import { Button } from '@patternfly/react-core'; +import { AddCircleOIcon } from '@patternfly/react-icons'; +import { CatalogIcon } from '@patternfly/react-icons/dist/esm/icons'; + +import { useTranslation } from '../../hooks/useTranslation'; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + textAlign: 'center', + gap: theme.spacing(2), + }, + icon: { + fontSize: 48, + color: 'var(--pf-t--global--icon--color--subtle)', + }, + heading: { + fontWeight: 500, + fontSize: '1.5rem', + lineHeight: '2rem', + letterSpacing: '-0.25px', + }, + uploadButton: { + textTransform: 'none', + borderRadius: 999, + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }, +})); + +type UploadResourceScreenProps = { + onUploadClick: () => void; +}; + +export const UploadResourceScreen = ({ + onUploadClick, +}: UploadResourceScreenProps) => { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( +
+ + + {t('notebook.view.upload.heading')} + + +
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/const.ts b/workspaces/lightspeed/plugins/lightspeed/src/const.ts index 145382beffc..7ab54371526 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/const.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/const.ts @@ -33,6 +33,34 @@ export const supportedFileTypes = { 'application/yaml': ['.yaml', '.yml'], }; +export const NOTEBOOK_MAX_FILES = 10; +export const NOTEBOOK_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB +export const UNTITLED_NOTEBOOK_NAME = 'Untitled Notebook'; + +export const NOTEBOOK_ALLOWED_EXTENSIONS: Record = { + 'text/plain': ['.txt', '.log'], + 'text/markdown': ['.md'], + 'application/pdf': ['.pdf'], + 'application/json': ['.json'], + 'application/x-yaml': ['.yaml', '.yml'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [ + '.docx', + ], + 'application/vnd.oasis.opendocument.text': ['.odt'], +}; + +export const NOTEBOOK_EXTENSION_TO_FILE_TYPE: Record = { + '.txt': 'txt', + '.md': 'md', + '.pdf': 'pdf', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.log': 'log', + '.docx': 'txt', + '.odt': 'txt', +}; + export const DEFAULT_SAMPLE_PROMPTS: SamplePrompts = [ createPrompt( 'prompts.codeReadability.title', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebook.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebook.ts new file mode 100644 index 00000000000..966e6c51ced --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebook.ts @@ -0,0 +1,49 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; + +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; +import { NotebookSession } from '../../types'; + +type CreateNotebookParams = { + name: string; + description?: string; +}; + +export const useCreateNotebook = (): UseMutationResult< + NotebookSession, + unknown, + CreateNotebookParams +> => { + const notebooksApi = useApi(notebooksApiRef); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ name, description }: CreateNotebookParams) => { + return notebooksApi.createSession(name, description); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notebooks', 'sessions'] }); + }, + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 9d9d8ca4fcb..c11218d85a6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -53,6 +53,18 @@ export const lightspeedMessages = { 'notebooks.updated.days': 'Updated {{days}} days ago', 'notebooks.updated.on': 'Updated on', + // Notebook view + 'notebook.view.title': 'Untitled notebook', + 'notebook.view.close': 'Close notebook', + 'notebook.view.documents.count': '{{count}} Documents', + 'notebook.view.documents.add': 'Add', + 'notebook.view.upload.heading': 'Upload a resource to get started', + 'notebook.view.upload.action': 'Upload a resource', + 'notebook.view.input.placeholder': 'Ask about your documents...', + 'notebook.view.sidebar.collapse': 'Collapse sidebar', + 'notebook.view.sidebar.expand': 'Expand sidebar', + 'notebook.view.sidebar.resize': 'Resize sidebar', + // Sample prompts - General Development 'prompts.codeReadability.title': 'Get Help On Code Readability', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts index aacd36dc032..be2bc85927f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts @@ -213,3 +213,75 @@ export type NotebookSession = { updated_at: string; metadata?: NotebookSessionMetadata; }; + +/** + * @public + * Supported file types for notebook document uploads + */ +export type NotebookDocumentSourceType = + | 'text' + | 'pdf' + | 'url' + | 'md' + | 'json' + | 'yaml' + | 'log'; + +/** + * @public + * Document within a notebook session + */ +export type SessionDocument = { + document_id: string; + title: string; + session_id: string; + user_id: string; + source_type: NotebookDocumentSourceType; + created_at: string; + metadata?: Record; +}; + +/** + * @public + * Response from the document upload endpoint (HTTP 202) + */ +export type UploadDocumentResponse = { + status: 'processing'; + document_id: string; + session_id: string; + message: string; +}; + +/** + * @public + * Document processing status from the status polling endpoint + */ +export type DocumentStatus = { + status: 'in_progress' | 'completed' | 'failed' | 'cancelled'; + document_id: string; + session_id: string; + error?: string; +}; + +/** + * @public + * Response wrapper for session creation + */ +export type SessionResponse = { + status: 'success' | 'error'; + session?: NotebookSession; + message?: string; + error?: string; +}; + +/** + * @public + * Response wrapper for document list + */ +export type DocumentListResponse = { + status: 'success' | 'error'; + session_id?: string; + documents?: SessionDocument[]; + count?: number; + error?: string; +}; From c0caa199d59a9362c130db230b1cdac661289f8c Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Mon, 6 Apr 2026 16:28:56 +0530 Subject: [PATCH 02/14] feat(lightspeed): creating new notebook Signed-off-by: its-mitesh-kumar --- .../src/components/LightSpeedChat.tsx | 3 +- .../components/notebooks/AddDocumentModal.tsx | 182 ++++++++++++++++++ .../components/notebooks/DocumentSidebar.tsx | 61 +++++- .../src/components/notebooks/NotebookView.tsx | 161 +++++++++++++++- .../notebooks/useDocumentStatusPolling.ts | 79 ++++++++ .../src/hooks/notebooks/useUploadDocument.ts | 59 ++++++ .../plugins/lightspeed/src/translations/de.ts | 30 +++ .../plugins/lightspeed/src/translations/es.ts | 30 +++ .../plugins/lightspeed/src/translations/fr.ts | 30 +++ .../plugins/lightspeed/src/translations/it.ts | 30 +++ .../plugins/lightspeed/src/translations/ja.ts | 30 +++ .../lightspeed/src/translations/ref.ts | 16 ++ .../src/utils/notebook-upload-utils.ts | 94 +++++++++ 13 files changed, 788 insertions(+), 17 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useUploadDocument.ts create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/utils/notebook-upload-utils.ts diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 95e1563f3c1..8a92c918225 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -1254,11 +1254,10 @@ export const LightspeedChat = ({ hasNotebooksAccess && activeNotebook && ( {}} - onAddDocument={() => {}} /> )} {showNotebooksPanel && diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx new file mode 100644 index 00000000000..c591d23eb89 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx @@ -0,0 +1,182 @@ +/* + * 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 { useState } from 'react'; + +import { makeStyles } from '@material-ui/core/styles'; +import CloseIcon from '@mui/icons-material/Close'; +import Alert from '@mui/material/Alert'; +import Dialog from '@mui/material/Dialog'; +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 { + MultipleFileUpload, + MultipleFileUploadMain, +} from '@patternfly/react-core'; +import { UploadIcon } from '@patternfly/react-icons'; + +import { NOTEBOOK_MAX_FILES } from '../../const'; +import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; +import { useTranslation } from '../../hooks/useTranslation'; +import { + getNotebookAcceptedFileTypes, + validateFiles, +} from '../../utils/notebook-upload-utils'; + +const useStyles = makeStyles(theme => ({ + dialogPaper: { + borderRadius: 24, + maxWidth: 578, + }, + dialogTitle: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '24px 24px 16px', + }, + titleText: { + fontWeight: 500, + fontSize: '1.25rem', + lineHeight: '1.625rem', + letterSpacing: '-0.25px', + }, + closeButton: { + color: theme.palette.grey[700], + }, + dialogContent: { + padding: '0 24px 24px', + }, + errorAlert: { + marginBottom: theme.spacing(2), + }, +})); + +type AddDocumentModalProps = { + isOpen: boolean; + onClose: () => void; + sessionId: string; + existingDocumentCount: number; + onFilesUploading?: (files: File[]) => void; + onUploadStarted?: (info: { fileName: string; documentId: string }) => void; + onUploadFailed?: (fileName: string) => void; +}; + +export const AddDocumentModal = ({ + isOpen, + onClose, + sessionId, + existingDocumentCount, + onFilesUploading, + onUploadStarted, + onUploadFailed, +}: AddDocumentModalProps) => { + const classes = useStyles(); + const { t } = useTranslation(); + const uploadMutation = useUploadDocument(); + const [validationErrors, setValidationErrors] = useState([]); + + const handleFileDrop = (_event: unknown, files: File[]) => { + setValidationErrors([]); + + const { valid, errors } = validateFiles(files, existingDocumentCount); + + if (errors.length > 0) { + setValidationErrors(errors); + return; + } + + if (valid.length > 0) { + onFilesUploading?.(valid); + for (const file of valid) { + uploadMutation + .mutateAsync({ sessionId, file }) + .then(data => { + onUploadStarted?.({ + fileName: file.name, + documentId: data.document_id, + }); + }) + .catch(() => { + onUploadFailed?.(file.name); + }); + } + setValidationErrors([]); + onClose(); + } + }; + + const handleClose = () => { + setValidationErrors([]); + onClose(); + }; + + return ( + + + + {t('notebook.upload.modal.title')} + + + + + + + + {validationErrors.length > 0 && ( + + {validationErrors + .map(errorKey => { + const message = (t as Function)(errorKey) as string; + return errorKey === 'notebook.upload.error.tooManyFiles' + ? message.replace('{{max}}', String(NOTEBOOK_MAX_FILES)) + : message; + }) + .join('\n')} + + )} + + + } + titleText={t('notebook.upload.modal.dragDropTitle')} + titleTextSeparator="or" + infoText={t('notebook.upload.modal.infoText')} + browseButtonText={t('notebook.upload.modal.browseButton')} + /> + + + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx index 87064d2538d..dcfe0055fd2 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx @@ -15,8 +15,8 @@ */ import { makeStyles, Typography } from '@material-ui/core'; -import { Button, Tooltip } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; +import { Button, Spinner, Tooltip } from '@patternfly/react-core'; +import { FileIcon, PlusCircleIcon } from '@patternfly/react-icons'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; @@ -69,15 +69,40 @@ const useStyles = makeStyles(theme => ({ marginTop: theme.spacing(2), display: 'flex', flexDirection: 'column', - gap: theme.spacing(1), + gap: theme.spacing(0.5), overflowY: 'auto', flex: 1, }, + documentItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: `${theme.spacing(1)}px ${theme.spacing(0.5)}px`, + borderRadius: 4, + }, + fileIcon: { + flexShrink: 0, + color: theme.palette.grey[500], + fontSize: '1rem', + }, + fileName: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '0.875rem', + lineHeight: '1.25rem', + }, + spinnerContainer: { + flexShrink: 0, + }, })); type DocumentSidebarProps = { notebookName: string; documents: SessionDocument[]; + uploadingFileNames: string[]; collapsed: boolean; onToggleCollapse: () => void; onAddDocument: () => void; @@ -86,6 +111,7 @@ type DocumentSidebarProps = { export const DocumentSidebar = ({ notebookName, documents, + uploadingFileNames, collapsed, onToggleCollapse, onAddDocument, @@ -97,6 +123,12 @@ export const DocumentSidebar = ({ return null; } + const uploadedNames = new Set(documents.map(d => d.title)); + const activePending = uploadingFileNames.filter( + name => !uploadedNames.has(name), + ); + const totalCount = documents.length + activePending.length; + return (
@@ -116,7 +148,7 @@ export const DocumentSidebar = ({
{t('notebook.view.documents.count', { - count: documents.length, + count: totalCount, } as any)}
- {documents.length > 0 && ( + {(documents.length > 0 || activePending.length > 0) && (
- {/* Document list items will be rendered here when documents exist */} + {documents.map(doc => ( +
+ + {doc.title} +
+ ))} + {activePending.map(fileName => ( +
+ + {fileName} +
+ +
+
+ ))}
)}
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index ea3ded71caa..0612072d995 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { makeStyles } from '@material-ui/core'; import { @@ -24,18 +24,27 @@ import { } from '@patternfly/chatbot'; import { Alert, + AlertActionCloseButton, + AlertGroup, + AlertVariant, Button, Drawer, DrawerContent, DrawerContentBody, DrawerPanelContent, Tooltip, + type AlertProps, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; import { UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { + useDocumentStatusPolling, + type PendingUpload, +} from '../../hooks/notebooks/useDocumentStatusPolling'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; +import { AddDocumentModal } from './AddDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; import { AddCircleFilledIcon, SidebarExpandIcon } from './SidebarCollapseIcon'; import { UploadResourceScreen } from './UploadResourceScreen'; @@ -100,28 +109,125 @@ const useStyles = makeStyles(theme => ({ margin: '0 auto', padding: `0 ${theme.spacing(3)}px ${theme.spacing(1)}px`, }, + toastAlertGroup: { + '--pf-v6-c-alert-group--m-toast--InsetInlineEnd': `${theme.spacing(2.5)}px`, + '--pf-v6-c-alert-group--m-toast--InsetBlockStart': `${theme.spacing(2.5)}px`, + '--pf-v6-c-alert-group--m-toast--MaxWidth': '350px', + }, + toastAlert: { + maxWidth: '350px', + '& .pf-v6-c-alert__title': { + margin: 0, + }, + }, })); type NotebookViewProps = { + sessionId: string; notebookName?: string; documents?: SessionDocument[]; onClose: () => void; - onUploadClick: () => void; - onAddDocument: () => void; }; export const NotebookView = ({ + sessionId, notebookName = UNTITLED_NOTEBOOK_NAME, documents = [], onClose, - onUploadClick, - onAddDocument, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [uploadingFileNames, setUploadingFileNames] = useState([]); + const [pendingUploads, setPendingUploads] = useState([]); + const [toastAlerts, setToastAlerts] = useState[]>([]); + const processedIds = useRef>(new Set()); + + const handleOpenUploadModal = () => setIsUploadModalOpen(true); + const handleCloseUploadModal = () => setIsUploadModalOpen(false); + + const handleFilesUploading = (files: File[]) => { + setUploadingFileNames(prev => [...prev, ...files.map(f => f.name)]); + }; + + const handleUploadStarted = (info: { + fileName: string; + documentId: string; + }) => { + setPendingUploads(prev => [ + ...prev, + { fileName: info.fileName, documentId: info.documentId }, + ]); + }; + + const handleUploadFailed = (fileName: string) => { + setUploadingFileNames(prev => prev.filter(n => n !== fileName)); + setToastAlerts(prev => [ + { + key: Date.now() + fileName, + title: (t as Function)('notebook.upload.failed', { + fileName, + }) as string, + variant: 'danger', + }, + ...prev, + ]); + }; + + const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); + + useEffect(() => { + const completedOrFailed = pollingResults.filter( + r => + (r.status === 'completed' || + r.status === 'failed' || + r.status === 'cancelled') && + !processedIds.current.has(r.documentId), + ); - const hasDocuments = documents.length > 0; + if (completedOrFailed.length === 0) return; + + const idsToRemove = new Set(); + const namesToRemove = new Set(); + const newAlerts: Partial[] = []; + + for (const result of completedOrFailed) { + processedIds.current.add(result.documentId); + idsToRemove.add(result.documentId); + namesToRemove.add(result.fileName); + + if (result.status === 'completed') { + newAlerts.push({ + key: Date.now() + result.documentId, + title: (t as Function)('notebook.upload.success', { + fileName: result.fileName, + }) as string, + variant: 'success', + }); + } else { + newAlerts.push({ + key: Date.now() + result.documentId, + title: (t as Function)('notebook.upload.failed', { + fileName: result.fileName, + }) as string, + variant: 'danger', + }); + } + } + + setPendingUploads(prev => prev.filter(u => !idsToRemove.has(u.documentId))); + setUploadingFileNames(prev => + prev.filter(name => !namesToRemove.has(name)), + ); + setToastAlerts(prev => [...newAlerts, ...prev]); + }, [pollingResults, t]); + + const handleRemoveToastAlert = (key: React.Key) => { + setToastAlerts(prev => prev.filter(a => a.key !== key)); + }; + + const hasDocuments = documents.length > 0 || uploadingFileNames.length > 0; const panelContent = ( setSidebarCollapsed(prev => !prev)} - onAddDocument={onAddDocument} + onAddDocument={handleOpenUploadModal} /> ); return (
+ {toastAlerts.length > 0 && ( + + {toastAlerts.map(({ key, title, variant }) => ( + handleRemoveToastAlert(key as React.Key)} + /> + } + /> + ))} + + )} @@ -200,7 +331,9 @@ export const NotebookView = ({
{!hasDocuments && ( - + )}
@@ -225,6 +358,16 @@ export const NotebookView = ({
+ +
); }; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts new file mode 100644 index 00000000000..85172a255d2 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts @@ -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 { useApi } from '@backstage/core-plugin-api'; + +import { useQueries } from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; +import { DocumentStatus } from '../../types'; + +export type PendingUpload = { + fileName: string; + documentId: string; +}; + +export type DocumentPollingResult = { + fileName: string; + documentId: string; + status: DocumentStatus['status'] | 'polling'; +}; + +const POLL_INTERVAL_MS = 3000; + +export const useDocumentStatusPolling = ( + sessionId: string, + pendingUploads: PendingUpload[], +): DocumentPollingResult[] => { + const notebooksApi = useApi(notebooksApiRef); + + const results = useQueries({ + queries: pendingUploads.map(upload => ({ + queryKey: ['notebooks', 'documentStatus', sessionId, upload.documentId], + queryFn: () => + notebooksApi.getDocumentStatus(sessionId, upload.documentId), + refetchInterval: (query: { + state: { data?: DocumentStatus; status: string }; + }) => { + if (query.state.status === 'error') { + return false; + } + const status = query.state.data?.status; + if ( + status === 'completed' || + status === 'failed' || + status === 'cancelled' + ) { + return false; + } + return POLL_INTERVAL_MS; + }, + retry: 2, + enabled: Boolean(upload.documentId), + })), + }); + + return pendingUploads.map((upload, index) => { + const result = results[index]; + const dataStatus = result?.data?.status; + const isQueryError = result?.isError; + return { + fileName: upload.fileName, + documentId: upload.documentId, + status: isQueryError ? 'failed' : (dataStatus ?? 'polling'), + }; + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useUploadDocument.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useUploadDocument.ts new file mode 100644 index 00000000000..6b9359c236e --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useUploadDocument.ts @@ -0,0 +1,59 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; + +import { + useMutation, + useQueryClient, + type UseMutationResult, +} from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; +import { NOTEBOOK_EXTENSION_TO_FILE_TYPE } from '../../const'; +import { UploadDocumentResponse } from '../../types'; + +type UploadDocumentParams = { + sessionId: string; + file: File; +}; + +const getFileType = (fileName: string): string => { + const lastDot = fileName.lastIndexOf('.'); + const ext = lastDot >= 0 ? fileName.slice(lastDot).toLowerCase() : ''; + return NOTEBOOK_EXTENSION_TO_FILE_TYPE[ext] ?? 'txt'; +}; + +export const useUploadDocument = (): UseMutationResult< + UploadDocumentResponse, + Error, + UploadDocumentParams +> => { + const notebooksApi = useApi(notebooksApiRef); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ sessionId, file }: UploadDocumentParams) => { + const fileType = getFileType(file.name); + return notebooksApi.uploadDocument(sessionId, file, fileType, file.name); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', variables.sessionId], + }); + }, + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index 93d914080d3..abf6ece989a 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -54,6 +54,36 @@ const lightspeedTranslationDe = createTranslationMessages({ 'notebooks.updated.yesterday': 'Vor 1 Tag aktualisiert', 'notebooks.updated.days': 'Vor {{days}} Tagen aktualisiert', 'notebooks.updated.on': 'Aktualisiert am', + + // Notebook view + 'notebook.view.title': 'Unbenanntes Notizbuch', + 'notebook.view.close': 'Notizbuch schließen', + 'notebook.view.documents.count': '{{count}} Dokumente', + 'notebook.view.documents.add': 'Hinzufügen', + 'notebook.view.upload.heading': + 'Laden Sie eine Ressource hoch, um zu beginnen', + 'notebook.view.upload.action': 'Ressource hochladen', + 'notebook.view.input.placeholder': 'Fragen Sie zu Ihren Dokumenten...', + 'notebook.view.sidebar.collapse': 'Seitenleiste einklappen', + 'notebook.view.sidebar.expand': 'Seitenleiste ausklappen', + 'notebook.view.sidebar.resize': 'Größe der Seitenleiste ändern', + 'notebook.view.documents.uploading': 'Dokument wird hochgeladen', + 'notebook.upload.success': '{{fileName}} erfolgreich hochgeladen.', + 'notebook.upload.failed': 'Hochladen von {{fileName}} fehlgeschlagen.', + + // Notebook upload modal + 'notebook.upload.modal.title': 'Dokument zum Notizbuch hinzufügen', + 'notebook.upload.modal.dragDropTitle': 'Dateien hierher ziehen und ablegen', + 'notebook.upload.modal.browseButton': 'Hochladen', + 'notebook.upload.modal.infoText': + 'Akzeptierte Dateitypen: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'Upload-Fehler: Nicht unterstützte Dateitypen gefunden. Bitte laden Sie nur unterstützte Dateitypen hoch.', + 'notebook.upload.error.fileTooLarge': + 'Upload-Fehler: Dateigröße überschreitet das Limit von 25 MB.', + 'notebook.upload.error.tooManyFiles': + 'Upload-Fehler: Maximal {{max}} Dateien erlaubt.', + 'prompts.codeReadability.title': 'Hilfe zur Code-Lesbarkeit erhalten', 'prompts.codeReadability.message': 'Können Sie mir Techniken vorschlagen, mit denen ich meinen Code lesbarer und wartungsfreundlicher gestalten kann?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 3af559c53be..1e735f5ff36 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -54,6 +54,36 @@ const lightspeedTranslationEs = createTranslationMessages({ 'notebooks.updated.yesterday': 'Actualizado hace 1 día', 'notebooks.updated.days': 'Actualizado hace {{days}} días', 'notebooks.updated.on': 'Actualizado el', + + // Notebook view + 'notebook.view.title': 'Cuaderno sin título', + 'notebook.view.close': 'Cerrar cuaderno', + 'notebook.view.documents.count': '{{count}} Documentos', + 'notebook.view.documents.add': 'Agregar', + 'notebook.view.upload.heading': 'Sube un recurso para empezar', + 'notebook.view.upload.action': 'Subir un recurso', + 'notebook.view.input.placeholder': 'Pregunta sobre tus documentos...', + 'notebook.view.sidebar.collapse': 'Contraer barra lateral', + 'notebook.view.sidebar.expand': 'Expandir barra lateral', + 'notebook.view.sidebar.resize': 'Redimensionar barra lateral', + 'notebook.view.documents.uploading': 'Subiendo documento', + 'notebook.upload.success': '{{fileName}} subido correctamente.', + 'notebook.upload.failed': 'Error al subir {{fileName}}.', + + // Notebook upload modal + 'notebook.upload.modal.title': 'Agregar un documento al cuaderno', + 'notebook.upload.modal.dragDropTitle': + 'Arrastra y suelta los archivos aquí', + 'notebook.upload.modal.browseButton': 'Subir', + 'notebook.upload.modal.infoText': + 'Tipos de archivo aceptados: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'Error de carga: se encontraron tipos de archivo no compatibles. Suba solo tipos de archivo compatibles.', + 'notebook.upload.error.fileTooLarge': + 'Error de carga: el tamaño del archivo supera el límite de 25 MB.', + 'notebook.upload.error.tooManyFiles': + 'Error de carga: se permiten un máximo de {{max}} archivos.', + 'prompts.codeReadability.title': 'Obtener ayuda sobre la legibilidad del código', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index 984df2ebad8..970fc2e4f78 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -54,6 +54,36 @@ const lightspeedTranslationFr = createTranslationMessages({ 'notebooks.updated.yesterday': 'Mis à jour il y a 1 jour', 'notebooks.updated.days': 'Mis à jour il y a {{days}} jours', 'notebooks.updated.on': 'Mis à jour le', + + // Notebook view + 'notebook.view.title': 'Carnet sans titre', + 'notebook.view.close': 'Fermer le carnet', + 'notebook.view.documents.count': '{{count}} Documents', + 'notebook.view.documents.add': 'Ajouter', + 'notebook.view.upload.heading': 'Chargez une ressource pour commencer', + 'notebook.view.upload.action': 'Charger une ressource', + 'notebook.view.input.placeholder': + 'Posez des questions sur vos documents...', + 'notebook.view.sidebar.collapse': 'Réduire la barre latérale', + 'notebook.view.sidebar.expand': 'Développer la barre latérale', + 'notebook.view.sidebar.resize': 'Redimensionner la barre latérale', + 'notebook.view.documents.uploading': 'Chargement du document', + 'notebook.upload.success': '{{fileName}} chargé avec succès.', + 'notebook.upload.failed': 'Échec du chargement de {{fileName}}.', + + // Notebook upload modal + 'notebook.upload.modal.title': 'Ajouter un document au carnet', + 'notebook.upload.modal.dragDropTitle': 'Glissez-déposez les fichiers ici', + 'notebook.upload.modal.browseButton': 'Charger', + 'notebook.upload.modal.infoText': + 'Types de fichiers acceptés : .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'Erreur de chargement : type(s) de fichier non pris en charge. Veuillez charger uniquement des types de fichiers pris en charge.', + 'notebook.upload.error.fileTooLarge': + 'Erreur de chargement : la taille du fichier dépasse la limite de 25 Mo.', + 'notebook.upload.error.tooManyFiles': + 'Erreur de chargement : {{max}} fichiers maximum autorisés.', + 'prompts.codeReadability.title': 'Obtenir de l’aide pour Décrypter le Code', 'prompts.codeReadability.message': 'Pourriez-vous me suggérer des techniques qui puissent rendre mon code plus lisible et facile d’entretien?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index cf511a25ddb..1eb435cf714 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -55,6 +55,36 @@ const lightspeedTranslationIt = createTranslationMessages({ 'notebooks.updated.yesterday': 'Aggiornato 1 giorno fa', 'notebooks.updated.days': 'Aggiornato {{days}} giorni fa', 'notebooks.updated.on': 'Aggiornato il', + + // Notebook view + 'notebook.view.title': 'Quaderno senza titolo', + 'notebook.view.close': 'Chiudi quaderno', + 'notebook.view.documents.count': '{{count}} Documenti', + 'notebook.view.documents.add': 'Aggiungi', + 'notebook.view.upload.heading': 'Carica una risorsa per iniziare', + 'notebook.view.upload.action': 'Carica una risorsa', + 'notebook.view.input.placeholder': + 'Chiedi informazioni sui tuoi documenti...', + 'notebook.view.sidebar.collapse': 'Comprimi barra laterale', + 'notebook.view.sidebar.expand': 'Espandi barra laterale', + 'notebook.view.sidebar.resize': 'Ridimensiona barra laterale', + 'notebook.view.documents.uploading': 'Caricamento documento', + 'notebook.upload.success': '{{fileName}} caricato con successo.', + 'notebook.upload.failed': 'Caricamento di {{fileName}} non riuscito.', + + // Notebook upload modal + 'notebook.upload.modal.title': 'Aggiungi un documento al quaderno', + 'notebook.upload.modal.dragDropTitle': 'Trascina e rilascia i file qui', + 'notebook.upload.modal.browseButton': 'Carica', + 'notebook.upload.modal.infoText': + 'Tipi di file accettati: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'Errore di caricamento: trovati tipi di file non supportati. Caricare solo tipi di file supportati.', + 'notebook.upload.error.fileTooLarge': + 'Errore di caricamento: la dimensione del file supera il limite di 25 MB.', + 'notebook.upload.error.tooManyFiles': + 'Errore di caricamento: sono consentiti al massimo {{max}} file.', + 'prompts.codeReadability.title': 'Ottenere aiuto sulla leggibilità del codice', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index dc98d45f3c5..f35beedbd92 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -54,6 +54,36 @@ const lightspeedTranslationJa = createTranslationMessages({ 'notebooks.updated.yesterday': '1日前に更新', 'notebooks.updated.days': '{{days}}日前に更新', 'notebooks.updated.on': '更新日', + + // Notebook view + 'notebook.view.title': '無題のノートブック', + 'notebook.view.close': 'ノートブックを閉じる', + 'notebook.view.documents.count': '{{count}} 件のドキュメント', + 'notebook.view.documents.add': '追加', + 'notebook.view.upload.heading': + 'リソースをアップロードして開始してください', + 'notebook.view.upload.action': 'リソースをアップロード', + 'notebook.view.input.placeholder': 'ドキュメントについて質問する...', + 'notebook.view.sidebar.collapse': 'サイドバーを折りたたむ', + 'notebook.view.sidebar.expand': 'サイドバーを展開する', + 'notebook.view.sidebar.resize': 'サイドバーのサイズを変更する', + 'notebook.view.documents.uploading': 'ドキュメントをアップロード中', + 'notebook.upload.success': '{{fileName}} のアップロードに成功しました。', + 'notebook.upload.failed': '{{fileName}} のアップロードに失敗しました。', + + // Notebook upload modal + 'notebook.upload.modal.title': 'ノートブックにドキュメントを追加', + 'notebook.upload.modal.dragDropTitle': 'ここにファイルをドラッグ&ドロップ', + 'notebook.upload.modal.browseButton': 'アップロード', + 'notebook.upload.modal.infoText': + '対応ファイル形式: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'アップロードエラー: サポートされていないファイル形式が見つかりました。サポートされているファイル形式のみをアップロードしてください。', + 'notebook.upload.error.fileTooLarge': + 'アップロードエラー: ファイルサイズが 25 MB の制限を超えています。', + 'notebook.upload.error.tooManyFiles': + 'アップロードエラー: 最大 {{max}} ファイルまで許可されています。', + 'prompts.codeReadability.title': 'コードの可読性に関するヘルプを利用する', 'prompts.codeReadability.message': 'コードの可読性と保守性を高めるための手法を提案してくれませんか?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index c11218d85a6..a27cfab6bc5 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -64,6 +64,22 @@ export const lightspeedMessages = { 'notebook.view.sidebar.collapse': 'Collapse sidebar', 'notebook.view.sidebar.expand': 'Expand sidebar', 'notebook.view.sidebar.resize': 'Resize sidebar', + 'notebook.view.documents.uploading': 'Uploading document', + 'notebook.upload.success': '{{fileName}} Successfully Uploaded.', + 'notebook.upload.failed': '{{fileName}} Upload Failed.', + + // Notebook upload modal + 'notebook.upload.modal.title': 'Add a document to Notebook', + 'notebook.upload.modal.dragDropTitle': 'Drag and drop files here', + 'notebook.upload.modal.browseButton': 'Upload', + 'notebook.upload.modal.infoText': + 'Accepted file types: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.error.unsupportedType': + 'Upload error: Unsupported file type(s) found. Please upload only supported file types.', + 'notebook.upload.error.fileTooLarge': + 'Upload error: File size exceeds 25 MB limit.', + 'notebook.upload.error.tooManyFiles': + 'Upload error: Maximum of {{max}} files allowed.', // Sample prompts - General Development 'prompts.codeReadability.title': 'Get Help On Code Readability', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/notebook-upload-utils.ts b/workspaces/lightspeed/plugins/lightspeed/src/utils/notebook-upload-utils.ts new file mode 100644 index 00000000000..5099b8fde7d --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/notebook-upload-utils.ts @@ -0,0 +1,94 @@ +/* + * 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 { + NOTEBOOK_ALLOWED_EXTENSIONS, + NOTEBOOK_MAX_FILE_SIZE_BYTES, + NOTEBOOK_MAX_FILES, +} from '../const'; + +const getAllowedExtensions = (): string[] => + Object.values(NOTEBOOK_ALLOWED_EXTENSIONS).flat(); + +const getFileExtension = (fileName: string): string => { + const lastDot = fileName.lastIndexOf('.'); + return lastDot >= 0 ? fileName.slice(lastDot).toLowerCase() : ''; +}; + +export type FileValidationResult = { + valid: File[]; + errors: string[]; +}; + +export const validateFileType = (file: File): boolean => { + const ext = getFileExtension(file.name); + return getAllowedExtensions().includes(ext); +}; + +export const validateFileSize = (file: File): boolean => + file.size <= NOTEBOOK_MAX_FILE_SIZE_BYTES; + +export const validateFileCount = ( + existingCount: number, + newCount: number, +): boolean => existingCount + newCount <= NOTEBOOK_MAX_FILES; + +export const validateFiles = ( + files: File[], + existingCount: number = 0, +): FileValidationResult => { + const errors: string[] = []; + const valid: File[] = []; + + if (!validateFileCount(existingCount, files.length)) { + errors.push('notebook.upload.error.tooManyFiles'); + return { valid: [], errors }; + } + + const oversizedFiles: string[] = []; + const unsupportedFiles: string[] = []; + + for (const file of files) { + let isValid = true; + + if (!validateFileType(file)) { + unsupportedFiles.push(file.name); + isValid = false; + } + + if (!validateFileSize(file)) { + oversizedFiles.push(file.name); + isValid = false; + } + + if (isValid) { + valid.push(file); + } + } + + if (unsupportedFiles.length > 0) { + errors.push('notebook.upload.error.unsupportedType'); + } + + if (oversizedFiles.length > 0) { + errors.push('notebook.upload.error.fileTooLarge'); + } + + return { valid, errors }; +}; + +export const getNotebookAcceptedFileTypes = (): Record => + NOTEBOOK_ALLOWED_EXTENSIONS; From 05b0a79c87d63bd60f007976d8580853cecb382b Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Wed, 8 Apr 2026 17:49:34 +0530 Subject: [PATCH 03/14] updating status of documents Signed-off-by: its-mitesh-kumar --- .../plugins/lightspeed/report-alpha.api.md | 23 +++ .../src/components/LightSpeedChat.tsx | 13 +- .../components/notebooks/AddDocumentModal.tsx | 32 +++- .../components/notebooks/DocumentSidebar.tsx | 23 ++- .../src/components/notebooks/FileTypeIcon.tsx | 80 +++++++++ .../src/components/notebooks/NotebookCard.tsx | 15 +- .../src/components/notebooks/NotebookView.tsx | 66 ++++++- .../src/components/notebooks/NotebooksTab.tsx | 3 + .../notebooks/OverwriteConfirmModal.tsx | 167 ++++++++++++++++++ .../notebooks/useDocumentStatusPolling.ts | 4 +- .../hooks/notebooks/useNotebookDocuments.ts | 36 ++++ .../plugins/lightspeed/src/translations/de.ts | 6 + .../plugins/lightspeed/src/translations/es.ts | 6 + .../plugins/lightspeed/src/translations/fr.ts | 6 + .../plugins/lightspeed/src/translations/it.ts | 6 + .../plugins/lightspeed/src/translations/ja.ts | 6 + .../lightspeed/src/translations/ref.ts | 6 + 17 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileTypeIcon.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md index 821e3f7b042..c5ec5f680d9 100644 --- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md +++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md @@ -62,6 +62,29 @@ export const lightspeedTranslationRef: TranslationRef< readonly 'notebooks.updated.yesterday': string; readonly 'notebooks.updated.days': string; readonly 'notebooks.updated.on': string; + readonly 'notebook.view.title': string; + readonly 'notebook.view.close': string; + readonly 'notebook.view.documents.count': string; + readonly 'notebook.view.documents.add': string; + readonly 'notebook.view.upload.heading': string; + readonly 'notebook.view.upload.action': string; + readonly 'notebook.view.input.placeholder': string; + readonly 'notebook.view.sidebar.collapse': string; + readonly 'notebook.view.sidebar.expand': string; + readonly 'notebook.view.sidebar.resize': string; + readonly 'notebook.view.documents.uploading': string; + readonly 'notebook.upload.success': string; + readonly 'notebook.upload.failed': string; + readonly 'notebook.upload.modal.title': string; + readonly 'notebook.upload.modal.dragDropTitle': string; + readonly 'notebook.upload.modal.browseButton': string; + readonly 'notebook.upload.modal.infoText': string; + readonly 'notebook.upload.error.unsupportedType': string; + readonly 'notebook.upload.error.fileTooLarge': string; + readonly 'notebook.upload.error.tooManyFiles': string; + readonly 'notebook.overwrite.modal.title': string; + readonly 'notebook.overwrite.modal.description': string; + readonly 'notebook.overwrite.modal.action': string; readonly 'conversation.delete.confirm.title': string; readonly 'conversation.delete.confirm.message': string; readonly 'conversation.delete.confirm.action': string; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 8a92c918225..74e334992ea 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -90,6 +90,7 @@ import { useSortSettings, } from '../hooks'; import { useCreateNotebook } from '../hooks/notebooks/useCreateNotebook'; +import { useNotebookDocuments } from '../hooks/notebooks/useNotebookDocuments'; import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; import { useLightspeedUpdatePermission } from '../hooks/useLightspeedUpdatePermission'; import { useTranslation } from '../hooks/useTranslation'; @@ -238,6 +239,12 @@ const useStyles = makeStyles(theme => ({ borderRadius: theme.spacing(1.5), display: 'flex', flexDirection: 'column', + '&:hover': { + borderColor: 'var(--pf-t--global--border--color--hover)', + borderWidth: '1px', + borderStyle: 'solid', + cursor: 'pointer', + }, }, notebookCardHeader: { padding: theme.spacing(2), @@ -385,6 +392,9 @@ export const LightspeedChat = ({ [], ); const createNotebookMutation = useCreateNotebook(); + const { data: notebookDocuments = [] } = useNotebookDocuments( + activeNotebook?.session_id, + ); const [conversationId, setConversationId] = useState(''); const [newChatCreated, setNewChatCreated] = useState(false); const [isSendButtonDisabled, setIsSendButtonDisabled] = @@ -1256,7 +1266,7 @@ export const LightspeedChat = ({ )} @@ -1270,6 +1280,7 @@ export const LightspeedChat = ({ classes={classes} openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} + onSelectNotebook={setActiveNotebook} onRename={setRenameNotebookId} onDelete={setDeleteNotebookId} onCreateNotebook={handleCreateNotebook} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx index c591d23eb89..c5671dd614b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx @@ -70,20 +70,22 @@ type AddDocumentModalProps = { isOpen: boolean; onClose: () => void; sessionId: string; - existingDocumentCount: number; + existingDocumentNames: string[]; onFilesUploading?: (files: File[]) => void; onUploadStarted?: (info: { fileName: string; documentId: string }) => void; onUploadFailed?: (fileName: string) => void; + onDuplicatesFound?: (files: File[]) => void; }; export const AddDocumentModal = ({ isOpen, onClose, sessionId, - existingDocumentCount, + existingDocumentNames, onFilesUploading, onUploadStarted, onUploadFailed, + onDuplicatesFound, }: AddDocumentModalProps) => { const classes = useStyles(); const { t } = useTranslation(); @@ -93,16 +95,25 @@ export const AddDocumentModal = ({ const handleFileDrop = (_event: unknown, files: File[]) => { setValidationErrors([]); - const { valid, errors } = validateFiles(files, existingDocumentCount); + const { valid, errors } = validateFiles( + files, + existingDocumentNames.length, + ); if (errors.length > 0) { setValidationErrors(errors); return; } - if (valid.length > 0) { - onFilesUploading?.(valid); - for (const file of valid) { + if (valid.length === 0) return; + + const existingNamesSet = new Set(existingDocumentNames); + const newFiles = valid.filter(f => !existingNamesSet.has(f.name)); + const duplicateFiles = valid.filter(f => existingNamesSet.has(f.name)); + + if (newFiles.length > 0) { + onFilesUploading?.(newFiles); + for (const file of newFiles) { uploadMutation .mutateAsync({ sessionId, file }) .then(data => { @@ -115,9 +126,14 @@ export const AddDocumentModal = ({ onUploadFailed?.(file.name); }); } - setValidationErrors([]); - onClose(); } + + if (duplicateFiles.length > 0) { + onDuplicatesFound?.(duplicateFiles); + } + + setValidationErrors([]); + onClose(); }; const handleClose = () => { diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx index dcfe0055fd2..41e82e8275e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx @@ -16,10 +16,11 @@ import { makeStyles, Typography } from '@material-ui/core'; import { Button, Spinner, Tooltip } from '@patternfly/react-core'; -import { FileIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { PlusCircleIcon } from '@patternfly/react-icons'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; +import { FileTypeIcon } from './FileTypeIcon'; import { SidebarCollapseIcon } from './SidebarCollapseIcon'; const useStyles = makeStyles(theme => ({ @@ -103,6 +104,7 @@ type DocumentSidebarProps = { notebookName: string; documents: SessionDocument[]; uploadingFileNames: string[]; + completedFileNames?: Set; collapsed: boolean; onToggleCollapse: () => void; onAddDocument: () => void; @@ -112,6 +114,7 @@ export const DocumentSidebar = ({ notebookName, documents, uploadingFileNames, + completedFileNames, collapsed, onToggleCollapse, onAddDocument, @@ -165,20 +168,22 @@ export const DocumentSidebar = ({
{documents.map(doc => (
- + {doc.title}
))} {activePending.map(fileName => (
- + {fileName} -
- -
+ {!completedFileNames?.has(fileName) && ( +
+ +
+ )}
))}
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileTypeIcon.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileTypeIcon.tsx new file mode 100644 index 00000000000..4a6e5517f29 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileTypeIcon.tsx @@ -0,0 +1,80 @@ +/* + * 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'; + +const FILE_TYPE_COLORS: Record = { + pdf: '#C9190B', + yaml: '#F0AB00', + yml: '#F0AB00', + json: '#F0AB00', + csv: '#3E8635', + txt: '#6A6E73', + md: '#0066CC', + log: '#6A6E73', + docx: '#004B95', + odt: '#009596', + html: '#EC7A08', + xml: '#0066CC', + rtf: '#8476D1', + pptx: '#C9190B', +}; + +const DEFAULT_COLOR = '#6A6E73'; + +const useStyles = makeStyles(() => ({ + badge: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 28, + height: 22, + padding: '0 4px', + borderRadius: 4, + border: '1.5px solid', + fontSize: '0.625rem', + fontWeight: 700, + textTransform: 'uppercase', + lineHeight: 1, + flexShrink: 0, + }, +})); + +type FileTypeIconProps = { + fileName: string; + className?: string; +}; + +const getExtension = (fileName: string): string => { + const lastDot = fileName.lastIndexOf('.'); + return lastDot >= 0 ? fileName.slice(lastDot + 1).toLowerCase() : ''; +}; + +export const FileTypeIcon = ({ fileName, className }: FileTypeIconProps) => { + const classes = useStyles(); + const ext = getExtension(fileName); + const color = FILE_TYPE_COLORS[ext] ?? DEFAULT_COLOR; + const label = ext || '?'; + + return ( + + {label} + + ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookCard.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookCard.tsx index d39b0667cfa..ba2451f7ee7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookCard.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookCard.tsx @@ -39,6 +39,7 @@ type NotebookCardProps = { classes: Record; openNotebookMenuId: string | null; setOpenNotebookMenuId: React.Dispatch>; + onClick: (notebook: NotebookSession) => void; onRename: (sessionId: string) => void; onDelete: (sessionId: string) => void; t: TranslationFunction; @@ -50,12 +51,18 @@ export const NotebookCard = ({ classes, openNotebookMenuId, setOpenNotebookMenuId, + onClick, onRename, onDelete, t, getDocumentsCount, }: NotebookCardProps) => ( - + onClick(notebook)} + > { + onClick={event => { + event.stopPropagation(); onRename(notebook.session_id); setOpenNotebookMenuId(null); }} @@ -102,7 +110,8 @@ export const NotebookCard = ({ { + onClick={event => { + event.stopPropagation(); onDelete(notebook.session_id); setOpenNotebookMenuId(null); }} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 0612072d995..e72e1161f5e 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -42,10 +42,12 @@ import { useDocumentStatusPolling, type PendingUpload, } from '../../hooks/notebooks/useDocumentStatusPolling'; +import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; import { AddDocumentModal } from './AddDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; +import { OverwriteConfirmModal } from './OverwriteConfirmModal'; import { AddCircleFilledIcon, SidebarExpandIcon } from './SidebarCollapseIcon'; import { UploadResourceScreen } from './UploadResourceScreen'; @@ -137,12 +139,18 @@ export const NotebookView = ({ }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); + const uploadMutation = useUploadDocument(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [uploadingFileNames, setUploadingFileNames] = useState([]); const [pendingUploads, setPendingUploads] = useState([]); const [toastAlerts, setToastAlerts] = useState[]>([]); const processedIds = useRef>(new Set()); + const [completedFileNames, setCompletedFileNames] = useState>( + new Set(), + ); + const [filesToOverwrite, setFilesToOverwrite] = useState([]); + const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); const handleOpenUploadModal = () => setIsUploadModalOpen(true); const handleCloseUploadModal = () => setIsUploadModalOpen(false); @@ -155,6 +163,7 @@ export const NotebookView = ({ fileName: string; documentId: string; }) => { + processedIds.current.delete(info.documentId); setPendingUploads(prev => [ ...prev, { fileName: info.fileName, documentId: info.documentId }, @@ -175,6 +184,39 @@ export const NotebookView = ({ ]); }; + const handleDuplicatesFound = (files: File[]) => { + setFilesToOverwrite(files); + setIsOverwriteModalOpen(true); + }; + + const handleOverwriteConfirm = () => { + const files = filesToOverwrite; + setIsOverwriteModalOpen(false); + setFilesToOverwrite([]); + + if (files.length === 0) return; + + setUploadingFileNames(prev => [...prev, ...files.map(f => f.name)]); + for (const file of files) { + uploadMutation + .mutateAsync({ sessionId, file }) + .then(data => { + handleUploadStarted({ + fileName: file.name, + documentId: data.document_id, + }); + }) + .catch(() => { + handleUploadFailed(file.name); + }); + } + }; + + const handleOverwriteCancel = () => { + setIsOverwriteModalOpen(false); + setFilesToOverwrite([]); + }; + const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); useEffect(() => { @@ -192,10 +234,16 @@ export const NotebookView = ({ const namesToRemove = new Set(); const newAlerts: Partial[] = []; + const newCompletedNames = new Set(); + for (const result of completedOrFailed) { processedIds.current.add(result.documentId); - idsToRemove.add(result.documentId); - namesToRemove.add(result.fileName); + if (result.status !== 'completed') { + idsToRemove.add(result.documentId); + namesToRemove.add(result.fileName); + } else { + newCompletedNames.add(result.fileName); + } if (result.status === 'completed') { newAlerts.push({ @@ -220,6 +268,9 @@ export const NotebookView = ({ setUploadingFileNames(prev => prev.filter(name => !namesToRemove.has(name)), ); + if (newCompletedNames.size > 0) { + setCompletedFileNames(prev => new Set([...prev, ...newCompletedNames])); + } setToastAlerts(prev => [...newAlerts, ...prev]); }, [pollingResults, t]); @@ -241,6 +292,7 @@ export const NotebookView = ({ notebookName={notebookName} documents={documents} uploadingFileNames={uploadingFileNames} + completedFileNames={completedFileNames} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(prev => !prev)} onAddDocument={handleOpenUploadModal} @@ -363,10 +415,18 @@ export const NotebookView = ({ isOpen={isUploadModalOpen} onClose={handleCloseUploadModal} sessionId={sessionId} - existingDocumentCount={documents.length} + existingDocumentNames={documents.map(d => d.title)} onFilesUploading={handleFilesUploading} onUploadStarted={handleUploadStarted} onUploadFailed={handleUploadFailed} + onDuplicatesFound={handleDuplicatesFound} + /> + + f.name)} />
); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx index c5f2fd6434b..9d43c105cee 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebooksTab.tsx @@ -31,6 +31,7 @@ type NotebooksTabProps = { classes: Record; openNotebookMenuId: string | null; setOpenNotebookMenuId: React.Dispatch>; + onSelectNotebook: (notebook: NotebookSession) => void; onRename: (sessionId: string) => void; onDelete: (sessionId: string) => void; onCreateNotebook: () => void; @@ -44,6 +45,7 @@ export const NotebooksTab = ({ classes, openNotebookMenuId, setOpenNotebookMenuId, + onSelectNotebook, onRename, onDelete, onCreateNotebook, @@ -96,6 +98,7 @@ export const NotebooksTab = ({ classes={classes} openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} + onClick={onSelectNotebook} onRename={onRename} onDelete={onDelete} t={t} diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx new file mode 100644 index 00000000000..8e52600a287 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx @@ -0,0 +1,167 @@ +/* + * 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 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 { useTranslation } from '../../hooks/useTranslation'; +import { FileTypeIcon } from './FileTypeIcon'; + +const useStyles = makeStyles(theme => ({ + dialogPaper: { + borderRadius: 24, + maxWidth: 578, + }, + dialogTitle: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '24px 24px 16px', + }, + titleText: { + fontWeight: 500, + fontSize: '1.25rem', + lineHeight: '1.625rem', + letterSpacing: '-0.25px', + }, + closeButton: { + color: theme.palette.grey[700], + }, + dialogContent: { + padding: '0 24px 24px', + }, + fileList: { + margin: 0, + padding: 0, + listStyle: 'none', + }, + fileItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: `${theme.spacing(2)}px 0`, + borderBottom: + '1px solid var(--pf-t--global--border--color--default, #c7c7c7)', + }, + fileName: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '0.875rem', + lineHeight: '1.25rem', + }, + dialogActions: { + justifyContent: 'left', + padding: theme.spacing(2.5), + gap: theme.spacing(1), + }, + overwriteButton: { + textTransform: 'none', + borderRadius: 999, + }, + cancelButton: { + textTransform: 'none', + borderRadius: 999, + }, + warningAlert: { + borderRadius: '6px', + }, +})); + +type OverwriteConfirmModalProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + fileNames: string[]; +}; + +export const OverwriteConfirmModal = ({ + isOpen, + onClose, + onConfirm, + fileNames, +}: OverwriteConfirmModalProps) => { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( + + + + {t('notebook.overwrite.modal.title')} + + + + + + + + + {t('notebook.overwrite.modal.description')} + + +
    + {fileNames.map(name => ( +
  • + + {name} +
  • + ))} +
+
+ + + + + +
+ ); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts index 85172a255d2..214e69440d8 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useDocumentStatusPolling.ts @@ -32,7 +32,7 @@ export type DocumentPollingResult = { status: DocumentStatus['status'] | 'polling'; }; -const POLL_INTERVAL_MS = 3000; +const POLL_INTERVAL_MS = 5000; export const useDocumentStatusPolling = ( sessionId: string, @@ -61,7 +61,7 @@ export const useDocumentStatusPolling = ( } return POLL_INTERVAL_MS; }, - retry: 2, + retry: 10, enabled: Boolean(upload.documentId), })), }); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts new file mode 100644 index 00000000000..9c70fbbd63a --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useNotebookDocuments.ts @@ -0,0 +1,36 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; + +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; +import { SessionDocument } from '../../types'; + +export const useNotebookDocuments = ( + sessionId?: string, +): UseQueryResult => { + const notebooksApi = useApi(notebooksApiRef); + return useQuery({ + queryKey: ['notebooks', 'documents', sessionId], + queryFn: async () => { + return await notebooksApi.listDocuments(sessionId!); + }, + enabled: Boolean(sessionId), + staleTime: 1000 * 60, + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index abf6ece989a..5205cf676f7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -84,6 +84,12 @@ const lightspeedTranslationDe = createTranslationMessages({ 'notebook.upload.error.tooManyFiles': 'Upload-Fehler: Maximal {{max}} Dateien erlaubt.', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': 'Dateien überschreiben?', + 'notebook.overwrite.modal.description': + 'Die folgenden Dateien existieren bereits in diesem Notizbuch. Möchten Sie sie mit den neuen Versionen überschreiben?', + 'notebook.overwrite.modal.action': 'Überschreiben', + 'prompts.codeReadability.title': 'Hilfe zur Code-Lesbarkeit erhalten', 'prompts.codeReadability.message': 'Können Sie mir Techniken vorschlagen, mit denen ich meinen Code lesbarer und wartungsfreundlicher gestalten kann?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 1e735f5ff36..3fe99736fbe 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -84,6 +84,12 @@ const lightspeedTranslationEs = createTranslationMessages({ 'notebook.upload.error.tooManyFiles': 'Error de carga: se permiten un máximo de {{max}} archivos.', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': '¿Sobrescribir archivos?', + 'notebook.overwrite.modal.description': + 'Los siguientes archivos ya existen en este cuaderno. ¿Desea sobrescribirlos con las nuevas versiones?', + 'notebook.overwrite.modal.action': 'Sobrescribir', + 'prompts.codeReadability.title': 'Obtener ayuda sobre la legibilidad del código', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index 970fc2e4f78..24abf745d5b 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -84,6 +84,12 @@ const lightspeedTranslationFr = createTranslationMessages({ 'notebook.upload.error.tooManyFiles': 'Erreur de chargement : {{max}} fichiers maximum autorisés.', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': 'Écraser les fichiers ?', + 'notebook.overwrite.modal.description': + 'Les fichiers suivants existent déjà dans ce carnet. Voulez-vous les écraser avec les nouvelles versions ?', + 'notebook.overwrite.modal.action': 'Écraser', + 'prompts.codeReadability.title': 'Obtenir de l’aide pour Décrypter le Code', 'prompts.codeReadability.message': 'Pourriez-vous me suggérer des techniques qui puissent rendre mon code plus lisible et facile d’entretien?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index 1eb435cf714..f7a41666ab6 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -85,6 +85,12 @@ const lightspeedTranslationIt = createTranslationMessages({ 'notebook.upload.error.tooManyFiles': 'Errore di caricamento: sono consentiti al massimo {{max}} file.', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': 'Sovrascrivere i file?', + 'notebook.overwrite.modal.description': + 'I seguenti file esistono già in questo quaderno. Vuoi sovrascriverli con le nuove versioni?', + 'notebook.overwrite.modal.action': 'Sovrascrivi', + 'prompts.codeReadability.title': 'Ottenere aiuto sulla leggibilità del codice', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index f35beedbd92..086c778cbf7 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -84,6 +84,12 @@ const lightspeedTranslationJa = createTranslationMessages({ 'notebook.upload.error.tooManyFiles': 'アップロードエラー: 最大 {{max}} ファイルまで許可されています。', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': 'ファイルを上書きしますか?', + 'notebook.overwrite.modal.description': + '以下のファイルはこのノートブックに既に存在します。新しいバージョンで上書きしますか?', + 'notebook.overwrite.modal.action': '上書き', + 'prompts.codeReadability.title': 'コードの可読性に関するヘルプを利用する', 'prompts.codeReadability.message': 'コードの可読性と保守性を高めるための手法を提案してくれませんか?', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index a27cfab6bc5..7f7682b4165 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -81,6 +81,12 @@ export const lightspeedMessages = { 'notebook.upload.error.tooManyFiles': 'Upload error: Maximum of {{max}} files allowed.', + // Notebook overwrite modal + 'notebook.overwrite.modal.title': 'Overwrite Files?', + 'notebook.overwrite.modal.description': + 'The following files already exist in this notebook. Do you want to overwrite them with the new versions?', + 'notebook.overwrite.modal.action': 'Overwrite', + // Sample prompts - General Development 'prompts.codeReadability.title': 'Get Help On Code Readability', 'prompts.codeReadability.message': From 104c86cc22fdca0e864abfb8d51853150f44cb9d Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Wed, 8 Apr 2026 18:15:41 +0530 Subject: [PATCH 04/14] updating the unit tests Signed-off-by: its-mitesh-kumar --- .../__tests__/DocumentSidebar.test.tsx | 142 ++++++++++++++++ .../__tests__/FileTypeIcon.test.tsx | 84 ++++++++++ .../__tests__/LightspeedChat.test.tsx | 13 ++ .../__tests__/NotebookCard.test.tsx | 151 +++++++++++++++++ .../__tests__/OverwriteConfirmModal.test.tsx | 153 ++++++++++++++++++ .../__tests__/notebook-upload-utils.test.ts | 153 ++++++++++++++++++ .../utils/__tests__/notebooks-utils.test.ts | 62 +++++++ 7 files changed, 758 insertions(+) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/DocumentSidebar.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileTypeIcon.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/NotebookCard.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/OverwriteConfirmModal.test.tsx create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebook-upload-utils.test.ts create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebooks-utils.test.ts diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/DocumentSidebar.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/DocumentSidebar.test.tsx new file mode 100644 index 00000000000..f01c397457f --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/DocumentSidebar.test.tsx @@ -0,0 +1,142 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; + +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import { SessionDocument } from '../../types'; +import { DocumentSidebar } from '../notebooks/DocumentSidebar'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +const mockDocument = (id: string, title: string): SessionDocument => ({ + document_id: id, + title, + session_id: 'session-1', + user_id: 'user-1', + source_type: 'text', + created_at: new Date().toISOString(), +}); + +describe('DocumentSidebar', () => { + const onToggleCollapse = jest.fn(); + const onAddDocument = jest.fn(); + + const defaultProps = { + notebookName: 'Test Notebook', + documents: [] as SessionDocument[], + uploadingFileNames: [] as string[], + collapsed: false, + onToggleCollapse, + onAddDocument, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the notebook name', () => { + render(); + expect(screen.getByText('Test Notebook')).toBeInTheDocument(); + }); + + it('should render nothing when collapsed', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('should display existing documents', () => { + const documents = [ + mockDocument('doc-1', 'readme.md'), + mockDocument('doc-2', 'config.yaml'), + ]; + render(); + + expect(screen.getByText('readme.md')).toBeInTheDocument(); + expect(screen.getByText('config.yaml')).toBeInTheDocument(); + }); + + it('should display FileTypeIcon badges for documents', () => { + const documents = [mockDocument('doc-1', 'report.pdf')]; + render(); + + expect(screen.getByText('pdf')).toBeInTheDocument(); + }); + + it('should display uploading files with spinners', () => { + render( + , + ); + + expect(screen.getByText('uploading.txt')).toBeInTheDocument(); + expect(screen.getByText('txt')).toBeInTheDocument(); + }); + + it('should hide spinner for completed uploads', () => { + const completedFileNames = new Set(['done.pdf']); + render( + , + ); + + expect(screen.getByText('done.pdf')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('should not show pending files that already appear in documents', () => { + const documents = [mockDocument('doc-1', 'existing.md')]; + render( + , + ); + + const items = screen.getAllByText('existing.md'); + expect(items).toHaveLength(1); + }); + + it('should call onAddDocument when add button is clicked', () => { + render(); + + const addButton = screen.getByText('Add'); + fireEvent.click(addButton); + + expect(onAddDocument).toHaveBeenCalledTimes(1); + }); + + it('should call onToggleCollapse when collapse button is clicked', () => { + render(); + + const collapseButton = screen.getByRole('button', { + name: 'Collapse sidebar', + }); + fireEvent.click(collapseButton); + + expect(onToggleCollapse).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileTypeIcon.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileTypeIcon.test.tsx new file mode 100644 index 00000000000..f26d7acd817 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileTypeIcon.test.tsx @@ -0,0 +1,84 @@ +/* + * 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 { FileTypeIcon } from '../notebooks/FileTypeIcon'; + +describe('FileTypeIcon', () => { + it('should render the file extension as label', () => { + render(); + expect(screen.getByText('pdf')).toBeInTheDocument(); + }); + + it('should render "?" for files without an extension', () => { + render(); + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('should render the extension in lowercase', () => { + render(); + expect(screen.getByText('md')).toBeInTheDocument(); + }); + + it('should apply the correct color for known file types', () => { + const { container } = render(); + const badge = container.querySelector('span'); + expect(badge).toHaveStyle({ color: '#C9190B', borderColor: '#C9190B' }); + }); + + it('should apply default color for unknown file types', () => { + const { container } = render(); + const badge = container.querySelector('span'); + expect(badge).toHaveStyle({ color: '#6A6E73', borderColor: '#6A6E73' }); + }); + + it.each([ + ['test.pdf', '#C9190B'], + ['test.yaml', '#F0AB00'], + ['test.yml', '#F0AB00'], + ['test.json', '#F0AB00'], + ['test.txt', '#6A6E73'], + ['test.md', '#0066CC'], + ['test.log', '#6A6E73'], + ['test.docx', '#004B95'], + ['test.odt', '#009596'], + ['test.html', '#EC7A08'], + ['test.csv', '#3E8635'], + ])('should use correct color for %s', (fileName, expectedColor) => { + const { container } = render(); + const badge = container.querySelector('span'); + expect(badge).toHaveStyle({ color: expectedColor }); + }); + + it('should append custom className when provided', () => { + const { container } = render( + , + ); + const badge = container.querySelector('span'); + expect(badge?.className).toContain('custom-class'); + }); + + it('should handle filenames with multiple dots', () => { + render(); + expect(screen.getByText('gz')).toBeInTheDocument(); + }); + + it('should handle filenames starting with a dot', () => { + render(); + expect(screen.getByText('gitignore')).toBeInTheDocument(); + }); +}); 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 ce51dfddab1..5f6fa972399 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -33,6 +33,7 @@ import { import userEvent from '@testing-library/user-event'; import { lightspeedApiRef } from '../../api/api'; +import { notebooksApiRef } from '../../api/notebooksApi'; import { useConversations, useNotebookSessions } from '../../hooks'; import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerContext'; import { mockUseTranslation } from '../../test-utils/mockTranslations'; @@ -150,12 +151,24 @@ const mockLightspeedApi = { isTopicRestrictionEnabled: jest.fn().mockResolvedValue(false), }; +const mockNotebooksApi = { + createSession: jest.fn().mockResolvedValue({}), + listSessions: jest.fn().mockResolvedValue([]), + renameSession: jest.fn().mockResolvedValue(undefined), + deleteSession: jest.fn().mockResolvedValue(undefined), + uploadDocument: jest.fn().mockResolvedValue({}), + listDocuments: jest.fn().mockResolvedValue([]), + deleteDocument: jest.fn().mockResolvedValue(undefined), + getDocumentStatus: jest.fn().mockResolvedValue({}), +}; + const setupLightspeedChat = () => ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/NotebookCard.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/NotebookCard.test.tsx new file mode 100644 index 00000000000..af4ae5b6bde --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/NotebookCard.test.tsx @@ -0,0 +1,151 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; + +import { mockT } from '../../test-utils/mockTranslations'; +import { NotebookSession } from '../../types'; +import { NotebookCard } from '../notebooks/NotebookCard'; + +const mockNotebook: NotebookSession = { + session_id: 'session-123', + user_id: 'user-1', + name: 'My Notebook', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + metadata: { + document_ids: ['doc-1', 'doc-2'], + }, +}; + +const mockClasses: Record = { + notebookCard: 'notebookCard', + notebookCardHeader: 'notebookCardHeader', + notebookDropdownMenu: 'notebookDropdownMenu', + notebookMenuButton: 'notebookMenuButton', + notebookDropdownList: 'notebookDropdownList', + notebookDropdownItem: 'notebookDropdownItem', + notebookCardHeaderActions: 'notebookCardHeaderActions', + notebookTitle: 'notebookTitle', + notebookTitleText: 'notebookTitleText', + notebookCardDivider: 'notebookCardDivider', + notebookCardBody: 'notebookCardBody', + notebookDocuments: 'notebookDocuments', + notebookUpdated: 'notebookUpdated', +}; + +describe('NotebookCard', () => { + const onClick = jest.fn(); + const onRename = jest.fn(); + const onDelete = jest.fn(); + const setOpenNotebookMenuId = jest.fn(); + const getDocumentsCount = jest.fn().mockReturnValue(2); + + const defaultProps = { + notebook: mockNotebook, + classes: mockClasses, + openNotebookMenuId: null as string | null, + setOpenNotebookMenuId, + onClick, + onRename, + onDelete, + t: mockT as any, + getDocumentsCount, + }; + + beforeEach(() => { + jest.clearAllMocks(); + getDocumentsCount.mockReturnValue(2); + }); + + it('should render the notebook name', () => { + render(); + expect(screen.getByText('My Notebook')).toBeInTheDocument(); + }); + + it('should render the document count', () => { + render(); + expect(screen.getByText(/2/)).toBeInTheDocument(); + }); + + it('should call onClick with notebook when card is clicked', () => { + render(); + const card = screen + .getByText('My Notebook') + .closest('[class*="notebookCard"]'); + fireEvent.click(card!); + expect(onClick).toHaveBeenCalledWith(mockNotebook); + }); + + it('should toggle dropdown menu when menu button is clicked', () => { + render(); + const menuButton = screen.getByRole('button', { name: /options/i }); + fireEvent.click(menuButton); + expect(setOpenNotebookMenuId).toHaveBeenCalled(); + }); + + it('should stop event propagation when menu toggle is clicked', () => { + render(); + onClick.mockClear(); + + const menuButton = screen.getByRole('button', { name: /options/i }); + fireEvent.click(menuButton); + + expect(onClick).not.toHaveBeenCalled(); + }); + + describe('dropdown actions', () => { + const propsWithOpenMenu = { + ...defaultProps, + openNotebookMenuId: 'session-123', + }; + + it('should render rename and delete options when menu is open', () => { + render(); + expect(screen.getByText('Rename')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('should call onRename and stop propagation when rename is clicked', () => { + render(); + onClick.mockClear(); + + const renameItem = screen.getByText('Rename'); + fireEvent.click(renameItem); + + expect(onRename).toHaveBeenCalledWith('session-123'); + expect(setOpenNotebookMenuId).toHaveBeenCalledWith(null); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should call onDelete and stop propagation when delete is clicked', () => { + render(); + onClick.mockClear(); + + const deleteItem = screen.getByText('Delete'); + fireEvent.click(deleteItem); + + expect(onDelete).toHaveBeenCalledWith('session-123'); + expect(setOpenNotebookMenuId).toHaveBeenCalledWith(null); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + it('should call getDocumentsCount with document_ids from metadata', () => { + render(); + expect(getDocumentsCount).toHaveBeenCalledWith(['doc-1', 'doc-2']); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/OverwriteConfirmModal.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/OverwriteConfirmModal.test.tsx new file mode 100644 index 00000000000..076cdd46065 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/OverwriteConfirmModal.test.tsx @@ -0,0 +1,153 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; + +import { mockUseTranslation } from '../../test-utils/mockTranslations'; +import { OverwriteConfirmModal } from '../notebooks/OverwriteConfirmModal'; + +jest.mock('../../hooks/useTranslation', () => ({ + useTranslation: jest.fn(() => mockUseTranslation()), +})); + +describe('OverwriteConfirmModal', () => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + const fileNames = ['report.pdf', 'data.yaml', 'notes.txt']; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the modal with file list when open', () => { + render( + , + ); + + expect(screen.getByText('report.pdf')).toBeInTheDocument(); + expect(screen.getByText('data.yaml')).toBeInTheDocument(); + expect(screen.getByText('notes.txt')).toBeInTheDocument(); + }); + + it('should not render when isOpen is false', () => { + render( + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should render a warning alert', () => { + render( + , + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('should render FileTypeIcon badges for each file', () => { + render( + , + ); + + expect(screen.getByText('pdf')).toBeInTheDocument(); + expect(screen.getByText('yaml')).toBeInTheDocument(); + expect(screen.getByText('txt')).toBeInTheDocument(); + }); + + it('should call onConfirm when overwrite button is clicked', () => { + render( + , + ); + + const overwriteButton = screen.getByRole('button', { + name: 'Overwrite', + }); + fireEvent.click(overwriteButton); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when cancel button is clicked', () => { + render( + , + ); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when close icon button is clicked', () => { + render( + , + ); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should render an empty list for no files', () => { + render( + , + ); + + expect(screen.queryByRole('listitem')).not.toBeInTheDocument(); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebook-upload-utils.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebook-upload-utils.test.ts new file mode 100644 index 00000000000..7297466f62e --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebook-upload-utils.test.ts @@ -0,0 +1,153 @@ +/* + * 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 { NOTEBOOK_MAX_FILE_SIZE_BYTES, NOTEBOOK_MAX_FILES } from '../../const'; +import { + getNotebookAcceptedFileTypes, + validateFileCount, + validateFiles, + validateFileSize, + validateFileType, +} from '../notebook-upload-utils'; + +const createFile = (name: string, size: number = 100): File => + new File([new ArrayBuffer(size)], name); + +describe('validateFileType', () => { + it.each(['.txt', '.log', '.md', '.pdf', '.json', '.yaml', '.yml'])( + 'should accept %s files', + ext => { + const file = createFile(`test${ext}`); + expect(validateFileType(file)).toBe(true); + }, + ); + + it.each(['.exe', '.zip', '.mp4', '.jpg', '.html', '.csv'])( + 'should reject %s files', + ext => { + const file = createFile(`test${ext}`); + expect(validateFileType(file)).toBe(false); + }, + ); + + it('should be case-insensitive for extensions', () => { + expect(validateFileType(createFile('test.PDF'))).toBe(true); + expect(validateFileType(createFile('test.Yaml'))).toBe(true); + }); + + it('should reject files with no extension', () => { + expect(validateFileType(createFile('Makefile'))).toBe(false); + }); +}); + +describe('validateFileSize', () => { + it('should accept files under the size limit', () => { + const file = createFile('small.txt', 1024); + expect(validateFileSize(file)).toBe(true); + }); + + it('should accept files at exactly the size limit', () => { + const file = createFile('exact.txt', NOTEBOOK_MAX_FILE_SIZE_BYTES); + expect(validateFileSize(file)).toBe(true); + }); + + it('should reject files over the size limit', () => { + const file = createFile('large.txt', NOTEBOOK_MAX_FILE_SIZE_BYTES + 1); + expect(validateFileSize(file)).toBe(false); + }); +}); + +describe('validateFileCount', () => { + it('should accept when total is within limit', () => { + expect(validateFileCount(5, 3)).toBe(true); + }); + + it('should accept when total is exactly the limit', () => { + expect(validateFileCount(NOTEBOOK_MAX_FILES - 1, 1)).toBe(true); + }); + + it('should reject when total exceeds limit', () => { + expect(validateFileCount(NOTEBOOK_MAX_FILES, 1)).toBe(false); + }); + + it('should reject when existing count already exceeds limit', () => { + expect(validateFileCount(NOTEBOOK_MAX_FILES + 1, 0)).toBe(false); + }); +}); + +describe('validateFiles', () => { + it('should return all valid files when everything passes', () => { + const files = [createFile('a.txt'), createFile('b.json')]; + const result = validateFiles(files); + expect(result.valid).toHaveLength(2); + expect(result.errors).toHaveLength(0); + }); + + it('should filter out unsupported file types', () => { + const files = [createFile('a.txt'), createFile('b.exe')]; + const result = validateFiles(files); + expect(result.valid).toHaveLength(1); + expect(result.valid[0].name).toBe('a.txt'); + expect(result.errors).toContain('notebook.upload.error.unsupportedType'); + }); + + it('should filter out oversized files', () => { + const files = [ + createFile('ok.txt', 100), + createFile('big.pdf', NOTEBOOK_MAX_FILE_SIZE_BYTES + 1), + ]; + const result = validateFiles(files); + expect(result.valid).toHaveLength(1); + expect(result.valid[0].name).toBe('ok.txt'); + expect(result.errors).toContain('notebook.upload.error.fileTooLarge'); + }); + + it('should reject all files when count exceeds limit', () => { + const files = [createFile('a.txt')]; + const result = validateFiles(files, NOTEBOOK_MAX_FILES); + expect(result.valid).toHaveLength(0); + expect(result.errors).toContain('notebook.upload.error.tooManyFiles'); + }); + + it('should report both unsupported type and oversized errors', () => { + const files = [ + createFile('bad.exe', 100), + createFile('big.txt', NOTEBOOK_MAX_FILE_SIZE_BYTES + 1), + ]; + const result = validateFiles(files); + expect(result.valid).toHaveLength(0); + expect(result.errors).toContain('notebook.upload.error.unsupportedType'); + expect(result.errors).toContain('notebook.upload.error.fileTooLarge'); + }); + + it('should use existingCount of 0 by default', () => { + const files = Array.from({ length: NOTEBOOK_MAX_FILES }, (_, i) => + createFile(`file${i}.txt`), + ); + const result = validateFiles(files); + expect(result.valid).toHaveLength(NOTEBOOK_MAX_FILES); + expect(result.errors).toHaveLength(0); + }); +}); + +describe('getNotebookAcceptedFileTypes', () => { + it('should return a record of MIME types to extension arrays', () => { + const accepted = getNotebookAcceptedFileTypes(); + expect(accepted).toHaveProperty('text/plain'); + expect(accepted).toHaveProperty('application/pdf'); + expect(accepted['text/plain']).toContain('.txt'); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebooks-utils.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebooks-utils.test.ts new file mode 100644 index 00000000000..a6ae03a3f24 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/notebooks-utils.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { formatUpdatedLabel } from '../notebooks-utils'; + +const mockT = (key: string, _params?: any) => { + const translations: Record = { + 'notebooks.updated.today': 'Updated today', + 'notebooks.updated.yesterday': 'Updated yesterday', + 'notebooks.updated.days': `Updated ${_params?.days} days ago`, + 'notebooks.updated.on': 'Updated on', + }; + return translations[key] ?? key; +}; + +describe('formatUpdatedLabel', () => { + it('should return "Updated today" for a date from today', () => { + const now = new Date().toISOString(); + expect(formatUpdatedLabel(now, mockT as any)).toBe('Updated today'); + }); + + it('should return "Updated yesterday" for a date from yesterday', () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + expect(formatUpdatedLabel(yesterday, mockT as any)).toBe( + 'Updated yesterday', + ); + }); + + it('should return "Updated N days ago" for dates within the last week', () => { + const threeDaysAgo = new Date( + Date.now() - 3 * 24 * 60 * 60 * 1000, + ).toISOString(); + expect(formatUpdatedLabel(threeDaysAgo, mockT as any)).toBe( + 'Updated 3 days ago', + ); + }); + + it('should return a formatted date for dates older than a week', () => { + const twoWeeksAgo = new Date( + Date.now() - 14 * 24 * 60 * 60 * 1000, + ).toISOString(); + const result = formatUpdatedLabel(twoWeeksAgo, mockT as any); + expect(result).toMatch(/^Updated on /); + }); + + it('should return the raw string for invalid dates', () => { + expect(formatUpdatedLabel('not-a-date', mockT as any)).toBe('not-a-date'); + }); +}); From 5c9183bcfe3c896620ef9e93e4272c70603adf5b Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Wed, 8 Apr 2026 19:34:34 +0530 Subject: [PATCH 05/14] handling overwrite in create flow Signed-off-by: its-mitesh-kumar --- .../lightspeed/src/components/notebooks/NotebookView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index e72e1161f5e..939f2cf22c4 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -36,6 +36,7 @@ import { type AlertProps, } from '@patternfly/react-core'; import { TimesIcon } from '@patternfly/react-icons'; +import { useQueryClient } from '@tanstack/react-query'; import { UNTITLED_NOTEBOOK_NAME } from '../../const'; import { @@ -139,6 +140,7 @@ export const NotebookView = ({ }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); + const queryClient = useQueryClient(); const uploadMutation = useUploadDocument(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); @@ -270,9 +272,12 @@ export const NotebookView = ({ ); if (newCompletedNames.size > 0) { setCompletedFileNames(prev => new Set([...prev, ...newCompletedNames])); + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', sessionId], + }); } setToastAlerts(prev => [...newAlerts, ...prev]); - }, [pollingResults, t]); + }, [pollingResults, t, queryClient, sessionId]); const handleRemoveToastAlert = (key: React.Key) => { setToastAlerts(prev => prev.filter(a => a.key !== key)); From 2974885272cd52d8bd6b1926adce0057a96e8532 Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Wed, 8 Apr 2026 20:14:31 +0530 Subject: [PATCH 06/14] adding changeset Signed-off-by: its-mitesh-kumar --- workspaces/lightspeed/.changeset/thin-humans-sparkle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 workspaces/lightspeed/.changeset/thin-humans-sparkle.md diff --git a/workspaces/lightspeed/.changeset/thin-humans-sparkle.md b/workspaces/lightspeed/.changeset/thin-humans-sparkle.md new file mode 100644 index 00000000000..10eb057f5b5 --- /dev/null +++ b/workspaces/lightspeed/.changeset/thin-humans-sparkle.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +--- + +Add notebook creation and document upload flow with file type validation, overwrite confirmation, collapsible document sidebar, file type icons, i18n support, and unit tests. From f40fc10fe36a2236319a6138b0bfb0b6d887bc91 Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Mon, 13 Apr 2026 17:02:55 +0530 Subject: [PATCH 07/14] feat(lightspeed): adding chat Signed-off-by: its-mitesh-kumar --- .../src/service/notebooks/notebooksRouters.ts | 11 + .../lightspeed/src/api/NotebooksApiClient.ts | 33 +++ .../lightspeed/src/api/notebooksApi.ts | 4 + .../src/components/LightSpeedChat.tsx | 70 +++-- .../__tests__/LightspeedChat.test.tsx | 3 + .../src/components/notebooks/NotebookView.tsx | 245 ++++++++++++++++-- .../notebooks/useCreateNotebookMessage.ts | 51 ++++ .../src/hooks/useConversationMessages.ts | 11 +- .../src/hooks/useCreateCoversationMessage.ts | 2 +- .../plugins/lightspeed/src/types.ts | 1 + 10 files changed, 391 insertions(+), 40 deletions(-) create mode 100644 workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts index 3aa06be62d2..4bd0806a134 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts @@ -72,6 +72,14 @@ export async function createNotebooksRouter( config.getOptionalNumber('lightspeed.servicePort') ?? DEFAULT_LIGHTSPEED_SERVICE_PORT; + const notebookModel = + config.getOptionalString('lightspeed.aiNotebooks.queryDefaults.model') ?? + ''; + const notebookProvider = + config.getOptionalString( + 'lightspeed.aiNotebooks.queryDefaults.provider_id', + ) ?? ''; + logger.info( `AI Notebooks connecting to Llama Stack at http://0.0.0.0:${llamaStackPort}`, ); @@ -400,7 +408,10 @@ export async function createNotebooksRouter( const session = await sessionService.readSession(sessionId, userId); const existingConversationId = session.metadata?.conversation_id; + req.body.model = notebookModel; + req.body.provider = notebookProvider; req.body.vector_store_ids = [sessionId]; + req.body.media_type = 'application/json'; if (existingConversationId) { req.body.conversation_id = existingConversationId; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts index d660f99223b..ab3ce695303 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/NotebooksApiClient.ts @@ -201,4 +201,37 @@ export class NotebooksApiClient implements NotebooksAPI { `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/documents/${encodeURIComponent(documentId)}/status`, ); } + + async querySession( + sessionId: string, + query: string, + ): Promise> { + const baseUrl = await this.getBaseUrl(); + const response = await this.fetchApi.fetch( + `${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/query`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }, + ); + + if (!response.body) { + throw new Error('Readable stream is not supported or there is no body.'); + } + + if (!response.ok) { + const reader = response.body.getReader(); + const { done, value } = await reader.read(); + const text = done ? '' : new TextDecoder('utf-8').decode(value); + const errorMessage = JSON.parse(text); + if (errorMessage?.error) { + throw new Error( + `failed to query notebook session: ${errorMessage.error}`, + ); + } + } + + return response.body.getReader(); + } } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts index 7ba87ccf194..3b95a63f1d1 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/api/notebooksApi.ts @@ -48,6 +48,10 @@ export type NotebooksAPI = { sessionId: string, documentId: string, ) => Promise; + querySession: ( + sessionId: string, + query: string, + ) => Promise>; }; /** diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 74e334992ea..33717e06c61 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -378,7 +378,7 @@ export const LightspeedChat = ({ const notebooksPermissionResolved = !notebooksPermissionLoading && hasNotebooksAccess; const { data: notebooks = [], refetch: refetchNotebooks } = - useNotebookSessions(activeTab === 1 && notebooksPermissionResolved); + useNotebookSessions(notebooksPermissionResolved); const hasNotebooks = notebooks.length > 0; const [openNotebookMenuId, setOpenNotebookMenuId] = useState( null, @@ -714,16 +714,40 @@ export const LightspeedChat = ({ ], ); + const notebookConversationIds = useMemo( + () => + new Set( + notebooks + .map(n => n.metadata?.conversation_id) + .filter((id): id is string => !!id), + ), + [notebooks], + ); + + const chatOnlyConversations = useMemo( + () => + conversations.filter( + c => !notebookConversationIds.has(c.conversation_id), + ), + [conversations, notebookConversationIds], + ); + const categorizedMessages = useMemo( () => getCategorizeMessages( - conversations, + chatOnlyConversations, pinnedChats, additionalMessageProps, t, selectedSort, ), - [additionalMessageProps, conversations, pinnedChats, t, selectedSort], + [ + additionalMessageProps, + chatOnlyConversations, + pinnedChats, + t, + selectedSort, + ], ); const filterConversations = useCallback( @@ -1098,19 +1122,21 @@ export const LightspeedChat = ({ )} - { - onNewChat(); - handleSelectedModel(item); - }} - models={models} - isPinningChatsEnabled={isPinningChatsEnabled} - isModelSelectorDisabled={isSendButtonDisabled} - setDisplayMode={setDisplayMode} - displayMode={displayMode} - onPinnedChatsToggle={handlePinningChatsToggle} - /> + {showChatPanel && ( + { + onNewChat(); + handleSelectedModel(item); + }} + models={models} + isPinningChatsEnabled={isPinningChatsEnabled} + isModelSelectorDisabled={isSendButtonDisabled} + setDisplayMode={setDisplayMode} + displayMode={displayMode} + onPinnedChatsToggle={handlePinningChatsToggle} + /> + )} {isFullscreenMode && ( <> @@ -1267,6 +1293,18 @@ export const LightspeedChat = ({ sessionId={activeNotebook.session_id} notebookName={activeNotebook.name} documents={notebookDocuments} + metadata={activeNotebook.metadata} + topicSummary={ + conversations.find( + c => + c.conversation_id === + activeNotebook.metadata?.conversation_id, + )?.topic_summary + } + userName={userName} + avatar={avatar} + profileLoading={profileLoading} + topicRestrictionEnabled={topicRestrictionEnabled} onClose={handleCloseNotebook} /> )} 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 5f6fa972399..0d7b4b25f72 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedChat.test.tsx @@ -160,6 +160,9 @@ const mockNotebooksApi = { listDocuments: jest.fn().mockResolvedValue([]), deleteDocument: jest.fn().mockResolvedValue(undefined), getDocumentStatus: jest.fn().mockResolvedValue({}), + querySession: jest.fn().mockResolvedValue({ + read: jest.fn().mockResolvedValue({ done: true, value: undefined }), + }), }; const setupLightspeedChat = () => ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 939f2cf22c4..94099c87e0f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -14,13 +14,16 @@ * limitations under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; -import { makeStyles } from '@material-ui/core'; +import { makeStyles, Typography } from '@material-ui/core'; import { + ChatbotContent, ChatbotFooter, ChatbotFootnote, + ChatbotWelcomePrompt, MessageBar, + MessageProps, } from '@patternfly/chatbot'; import { Alert, @@ -38,14 +41,19 @@ import { import { TimesIcon } from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; -import { UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { TEMP_CONVERSATION_ID, UNTITLED_NOTEBOOK_NAME } from '../../const'; +import { useCreateNotebookMessage } from '../../hooks/notebooks/useCreateNotebookMessage'; import { useDocumentStatusPolling, type PendingUpload, } from '../../hooks/notebooks/useDocumentStatusPolling'; import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; +import { useConversationMessages } from '../../hooks/useConversationMessages'; +import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage'; import { useTranslation } from '../../hooks/useTranslation'; -import { SessionDocument } from '../../types'; +import { useWelcomePrompts } from '../../hooks/useWelcomePrompts'; +import { NotebookSessionMetadata, SessionDocument } from '../../types'; +import { LightspeedChatBox } from '../LightspeedChatBox'; import { AddDocumentModal } from './AddDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; import { OverwriteConfirmModal } from './OverwriteConfirmModal'; @@ -56,7 +64,8 @@ const useStyles = makeStyles(theme => ({ root: { display: 'flex', flexDirection: 'column', - height: '100%', + flex: 1, + minHeight: 0, backgroundColor: 'var(--pf-t--global--background--color--primary--default)', }, drawerContainer: { @@ -123,12 +132,64 @@ const useStyles = makeStyles(theme => ({ margin: 0, }, }, + welcomeContainer: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflow: 'auto', + }, + notebookContentArea: { + width: '100%', + maxWidth: 816, + margin: '0 auto', + padding: `0 ${theme.spacing(3)}px`, + }, + notebookHeading: { + fontSize: '2rem', + fontWeight: 500, + lineHeight: 1.25, + padding: `${theme.spacing(1)}px 0`, + }, + notebookSummary: { + fontSize: '1rem', + lineHeight: 2, + color: 'var(--pf-t--global--text--color--regular)', + paddingTop: theme.spacing(0.5), + }, + promptSuggestions: { + width: '100%', + maxWidth: 816, + margin: '0 auto', + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, + }, + fullWidth: { + maxWidth: 'unset', + }, + footerFullWidth: { + '&>.pf-chatbot__footer-container': { + width: '95% !important', + maxWidth: 'unset !important', + }, + }, + chatContent: { + minHeight: 0, + display: 'flex', + flexDirection: 'column', + flex: 1, + }, })); type NotebookViewProps = { sessionId: string; notebookName?: string; documents?: SessionDocument[]; + metadata?: NotebookSessionMetadata; + topicSummary?: string; + userName?: string; + avatar?: string; + profileLoading: boolean; + topicRestrictionEnabled: boolean; onClose: () => void; }; @@ -136,12 +197,96 @@ export const NotebookView = ({ sessionId, notebookName = UNTITLED_NOTEBOOK_NAME, documents = [], + metadata, + topicSummary, + userName, + avatar, + profileLoading, + topicRestrictionEnabled, onClose, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); const queryClient = useQueryClient(); const uploadMutation = useUploadDocument(); + const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); + + const [conversationId, setConversationId] = useState( + metadata?.conversation_id ?? TEMP_CONVERSATION_ID, + ); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const [announcement, setAnnouncement] = useState( + undefined, + ); + + const onComplete = useCallback( + (message: string) => { + setIsSendButtonDisabled(false); + setAnnouncement(`Message from Bot: ${message}`); + queryClient.invalidateQueries({ + queryKey: ['conversationMessages', conversationId], + }); + }, + [queryClient, conversationId], + ); + + const onStart = useCallback((conv_id: string) => { + setConversationId(conv_id); + }, []); + + const createMessageAdapter = useCallback( + async (vars: CreateMessageVariables) => { + return notebookCreateMessage({ + prompt: vars.prompt, + sessionId, + }); + }, + [notebookCreateMessage, sessionId], + ); + + const { conversationMessages, handleInputPrompt, scrollToBottomRef } = + useConversationMessages( + conversationId, + userName, + '', + '', + avatar, + onComplete, + onStart, + createMessageAdapter, + ); + + const [messages, setMessages] = + useState(conversationMessages); + + useEffect(() => { + setMessages(conversationMessages); + }, [conversationMessages]); + + const sendMessage = useCallback( + (message: string | number) => { + setAnnouncement( + t('conversation.announcement.userMessage' as any, { + prompt: message.toString(), + }), + ); + handleInputPrompt(message.toString(), []); + setIsSendButtonDisabled(true); + }, + [handleInputPrompt, t], + ); + + const samplePrompts = useWelcomePrompts(); + const welcomePrompts = + samplePrompts?.map(prompt => { + const p = prompt as { title: string; message: string }; + return { + title: p.title, + message: p.message, + onClick: () => sendMessage(p.message), + }; + }) ?? []; + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [uploadingFileNames, setUploadingFileNames] = useState([]); @@ -305,6 +450,63 @@ export const NotebookView = ({ ); + const renderMainContent = () => { + if (!hasDocuments) { + return ; + } + if (messages.length > 0) { + return ( + + + + ); + } + return ( +
+
+ + {t('disclaimer.withoutValidation')} + +
+
+ + {notebookName} + + {topicSummary && ( + + {topicSummary} + + )} +
+ {welcomePrompts.length > 0 && ( +
+ +
+ )} +
+ ); + }; + return (
{toastAlerts.length > 0 && ( @@ -386,26 +588,27 @@ export const NotebookView = ({
-
- {!hasDocuments && ( - - )} -
- -
- - {t('disclaimer.withoutValidation')} - -
- - +
{renderMainContent()}
+ + {!hasDocuments && ( +
+ + {t('disclaimer.withoutValidation')} + +
+ )} + + {}} + isSendButtonDisabled={isSendButtonDisabled} + onSendMessage={sendMessage} placeholder={t('notebook.view.input.placeholder')} /> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts new file mode 100644 index 00000000000..2765540b06c --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/notebooks/useCreateNotebookMessage.ts @@ -0,0 +1,51 @@ +/* + * 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 { useApi } from '@backstage/core-plugin-api'; + +import { useMutation, type UseMutationResult } from '@tanstack/react-query'; + +import { notebooksApiRef } from '../../api/notebooksApi'; + +export type CreateNotebookMessageVariables = { + prompt: string; + sessionId: string; +}; + +export const useCreateNotebookMessage = (): UseMutationResult< + ReadableStreamDefaultReader, + Error, + CreateNotebookMessageVariables +> => { + const notebooksApi = useApi(notebooksApiRef); + + return useMutation({ + mutationFn: async ({ + prompt, + sessionId, + }: CreateNotebookMessageVariables) => { + if (!sessionId) { + throw new Error('Failed to generate AI response'); + } + + return await notebooksApi.querySession(sessionId, prompt); + }, + onError: error => { + // eslint-disable-next-line + console.warn(error); + }, + }); +}; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts index ca60832fa0a..3cbef275614 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useConversationMessages.ts @@ -40,7 +40,10 @@ import { getTimestamp, transformDocumentsToSources, } from '../utils/lightspeed-chatbox-utils'; -import { useCreateConversationMessage } from './useCreateCoversationMessage'; +import { + CreateMessageVariables, + useCreateConversationMessage, +} from './useCreateCoversationMessage'; // Fetch all conversation messages export const useFetchConversationMessages = ( @@ -103,8 +106,12 @@ export const useConversationMessages = ( avatar: string = userAvatar, onComplete?: (message: string) => void, onStart?: (conversation_id: string) => void, + createMessageOverride?: ( + vars: CreateMessageVariables, + ) => Promise>, ): UseConversationMessagesReturn => { - const { mutateAsync: createMessage } = useCreateConversationMessage(); + const { mutateAsync: defaultCreateMessage } = useCreateConversationMessage(); + const createMessage = createMessageOverride ?? defaultCreateMessage; const scrollToBottomRef = useRef(null); const [currentConversation, setCurrentConversation] = diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts index f73590a8f6c..03749d300f0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useCreateCoversationMessage.ts @@ -21,7 +21,7 @@ import { useMutation, type UseMutationResult } from '@tanstack/react-query'; import { lightspeedApiRef } from '../api/api'; import { Attachment } from '../types'; -type CreateMessageVariables = { +export type CreateMessageVariables = { prompt: string; selectedModel: string; selectedProvider: string; diff --git a/workspaces/lightspeed/plugins/lightspeed/src/types.ts b/workspaces/lightspeed/plugins/lightspeed/src/types.ts index be2bc85927f..024cfef8352 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/types.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/types.ts @@ -198,6 +198,7 @@ export type NotebookSessionMetadata = { tags?: string[]; project?: string; document_ids?: string[]; + conversation_id?: string; }; /** From e70c57b0ce9b6742bdb4cd55df9df62f5d7e24f1 Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Mon, 13 Apr 2026 20:17:03 +0530 Subject: [PATCH 08/14] delete of document Signed-off-by: its-mitesh-kumar --- .../components/notebooks/DocumentSidebar.tsx | 80 ++++++++++++++++++- .../src/components/notebooks/NotebookView.tsx | 32 ++++++++ .../plugins/lightspeed/src/translations/de.ts | 1 + .../plugins/lightspeed/src/translations/es.ts | 1 + .../plugins/lightspeed/src/translations/fr.ts | 1 + .../plugins/lightspeed/src/translations/it.ts | 1 + .../plugins/lightspeed/src/translations/ja.ts | 1 + .../lightspeed/src/translations/ref.ts | 1 + 8 files changed, 116 insertions(+), 2 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx index 41e82e8275e..4f641a66858 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/DocumentSidebar.tsx @@ -14,9 +14,19 @@ * limitations under the License. */ +import { useState } from 'react'; + import { makeStyles, Typography } from '@material-ui/core'; -import { Button, Spinner, Tooltip } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; +import { + Button, + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + Spinner, + Tooltip, +} from '@patternfly/react-core'; +import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons'; import { useTranslation } from '../../hooks/useTranslation'; import { SessionDocument } from '../../types'; @@ -98,6 +108,17 @@ const useStyles = makeStyles(theme => ({ spinnerContainer: { flexShrink: 0, }, + kebabToggle: { + padding: 0, + flexShrink: 0, + }, + kebabDropdownMenu: { + '& .pf-v6-c-menu__list': { + paddingInlineStart: 0, + marginBlockStart: 0, + marginBlockEnd: 0, + }, + }, })); type DocumentSidebarProps = { @@ -105,9 +126,11 @@ type DocumentSidebarProps = { documents: SessionDocument[]; uploadingFileNames: string[]; completedFileNames?: Set; + deletingDocumentIds?: Set; collapsed: boolean; onToggleCollapse: () => void; onAddDocument: () => void; + onDeleteDocument?: (documentId: string) => void; }; export const DocumentSidebar = ({ @@ -115,12 +138,15 @@ export const DocumentSidebar = ({ documents, uploadingFileNames, completedFileNames, + deletingDocumentIds, collapsed, onToggleCollapse, onAddDocument, + onDeleteDocument, }: DocumentSidebarProps) => { const classes = useStyles(); const { t } = useTranslation(); + const [openMenuDocId, setOpenMenuDocId] = useState(null); if (collapsed) { return null; @@ -170,6 +196,56 @@ export const DocumentSidebar = ({
{doc.title} + {deletingDocumentIds?.has(doc.document_id) ? ( +
+ +
+ ) : ( + + setOpenMenuDocId(isOpen ? doc.document_id : null) + } + toggle={toggleRef => ( + { + event.stopPropagation(); + setOpenMenuDocId(current => + current === doc.document_id ? null : doc.document_id, + ); + }} + aria-label={t('notebook.document.delete')} + > + + + )} + > + + { + event.stopPropagation(); + setOpenMenuDocId(null); + onDeleteDocument?.(doc.document_id); + }} + > + {t('notebook.document.delete')} + + + + )}
))} {activePending.map(fileName => ( diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 94099c87e0f..ab8266245a4 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -16,6 +16,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; + import { makeStyles, Typography } from '@material-ui/core'; import { ChatbotContent, @@ -41,6 +43,7 @@ import { import { TimesIcon } from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; +import { notebooksApiRef } from '../../api/notebooksApi'; import { TEMP_CONVERSATION_ID, UNTITLED_NOTEBOOK_NAME } from '../../const'; import { useCreateNotebookMessage } from '../../hooks/notebooks/useCreateNotebookMessage'; import { @@ -108,12 +111,15 @@ const useStyles = makeStyles(theme => ({ drawerContentBody: { backgroundColor: 'var(--pf-t--global--background--color--secondary--default)', + height: '100%', }, contentColumn: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, + minHeight: 0, + overflow: 'hidden', }, alertContainer: { width: '100%', @@ -177,6 +183,7 @@ const useStyles = makeStyles(theme => ({ display: 'flex', flexDirection: 'column', flex: 1, + overflow: 'auto', }, })); @@ -208,6 +215,7 @@ export const NotebookView = ({ const classes = useStyles(); const { t } = useTranslation(); const queryClient = useQueryClient(); + const notebooksApi = useApi(notebooksApiRef); const uploadMutation = useUploadDocument(); const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); @@ -218,6 +226,28 @@ export const NotebookView = ({ const [announcement, setAnnouncement] = useState( undefined, ); + const [deletingDocumentIds, setDeletingDocumentIds] = useState>( + new Set(), + ); + + const handleDeleteDocument = useCallback( + async (documentId: string) => { + setDeletingDocumentIds(prev => new Set(prev).add(documentId)); + try { + await notebooksApi.deleteDocument(sessionId, documentId); + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', sessionId], + }); + } finally { + setDeletingDocumentIds(prev => { + const next = new Set(prev); + next.delete(documentId); + return next; + }); + } + }, + [notebooksApi, sessionId, queryClient], + ); const onComplete = useCallback( (message: string) => { @@ -443,9 +473,11 @@ export const NotebookView = ({ documents={documents} uploadingFileNames={uploadingFileNames} completedFileNames={completedFileNames} + deletingDocumentIds={deletingDocumentIds} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(prev => !prev)} onAddDocument={handleOpenUploadModal} + onDeleteDocument={handleDeleteDocument} /> ); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts index 5205cf676f7..fd8b3aec32a 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/de.ts @@ -89,6 +89,7 @@ const lightspeedTranslationDe = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Die folgenden Dateien existieren bereits in diesem Notizbuch. Möchten Sie sie mit den neuen Versionen überschreiben?', 'notebook.overwrite.modal.action': 'Überschreiben', + 'notebook.document.delete': 'Löschen', 'prompts.codeReadability.title': 'Hilfe zur Code-Lesbarkeit erhalten', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts index 3fe99736fbe..9f29abda564 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/es.ts @@ -89,6 +89,7 @@ const lightspeedTranslationEs = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Los siguientes archivos ya existen en este cuaderno. ¿Desea sobrescribirlos con las nuevas versiones?', 'notebook.overwrite.modal.action': 'Sobrescribir', + 'notebook.document.delete': 'Eliminar', 'prompts.codeReadability.title': 'Obtener ayuda sobre la legibilidad del código', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts index 24abf745d5b..01d59b38383 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/fr.ts @@ -89,6 +89,7 @@ const lightspeedTranslationFr = createTranslationMessages({ 'notebook.overwrite.modal.description': 'Les fichiers suivants existent déjà dans ce carnet. Voulez-vous les écraser avec les nouvelles versions ?', 'notebook.overwrite.modal.action': 'Écraser', + 'notebook.document.delete': 'Supprimer', 'prompts.codeReadability.title': 'Obtenir de l’aide pour Décrypter le Code', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts index f7a41666ab6..3ab6cfbb28c 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/it.ts @@ -90,6 +90,7 @@ const lightspeedTranslationIt = createTranslationMessages({ 'notebook.overwrite.modal.description': 'I seguenti file esistono già in questo quaderno. Vuoi sovrascriverli con le nuove versioni?', 'notebook.overwrite.modal.action': 'Sovrascrivi', + 'notebook.document.delete': 'Elimina', 'prompts.codeReadability.title': 'Ottenere aiuto sulla leggibilità del codice', diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts index 086c778cbf7..257ef3ce770 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ja.ts @@ -89,6 +89,7 @@ const lightspeedTranslationJa = createTranslationMessages({ 'notebook.overwrite.modal.description': '以下のファイルはこのノートブックに既に存在します。新しいバージョンで上書きしますか?', 'notebook.overwrite.modal.action': '上書き', + 'notebook.document.delete': '削除', 'prompts.codeReadability.title': 'コードの可読性に関するヘルプを利用する', 'prompts.codeReadability.message': diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts index 7f7682b4165..6fa59085187 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts +++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts @@ -86,6 +86,7 @@ export const lightspeedMessages = { 'notebook.overwrite.modal.description': 'The following files already exist in this notebook. Do you want to overwrite them with the new versions?', 'notebook.overwrite.modal.action': 'Overwrite', + 'notebook.document.delete': 'Delete', // Sample prompts - General Development 'prompts.codeReadability.title': 'Get Help On Code Readability', From f6290c165892db805991c3fd4e3831dfc36935a8 Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Fri, 24 Apr 2026 23:34:04 +0530 Subject: [PATCH 09/14] formatting the query response Signed-off-by: its-mitesh-kumar --- .../src/service/notebooks/notebooksRouters.ts | 144 ++++++++++++------ .../src/components/LightSpeedChat.tsx | 33 ++-- .../components/LightspeedChatBoxHeader.tsx | 62 ++++---- .../hooks/notebooks/useNotebookDocuments.ts | 2 +- 4 files changed, 150 insertions(+), 91 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts index f60356a3aca..0701856666a 100644 --- a/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/lightspeed/plugins/lightspeed-backend/src/service/notebooks/notebooksRouters.ts @@ -26,7 +26,7 @@ import express, { Router } from 'express'; import { lightspeedNotebooksUsePermission } from '@red-hat-developer-hub/backstage-plugin-lightspeed-common'; -import { Readable } from 'stream'; +import { Readable, Transform } from 'stream'; import { DEFAULT_LIGHTSPEED_SERVICE_PORT, @@ -153,52 +153,115 @@ export async function createNotebooksRouter( } }; - const createConversationIdCaptureTransform = ( + /** + * Transforms Responses API SSE (event:/data: lines) into the legacy + * streaming format that the frontend useConversationMessages hook expects: + * data: {"event": "", "data": {...}}\n\n + * + * Also captures the conversation_id from the first response.created event + * and persists it on the session when it is new. + */ + const createResponsesApiTransform = ( session: any, sessionId: string, userId: string, ) => { - const { Transform } = require('stream'); - let captured = false; let buffer = ''; + let conversationCaptured = !!session.metadata?.conversation_id; return new Transform({ transform(chunk: any, _encoding: any, callback: any) { - this.push(chunk); + buffer += chunk.toString(); - if (!captured) { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = buffer.endsWith('\n') ? '' : lines.pop() || ''; + const blocks = buffer.split('\n\n'); + buffer = blocks.pop()!; + + for (const block of blocks) { + if (!block.trim()) continue; + + const lines = block.split('\n'); + let eventType = ''; + let dataLine = ''; + + for (const line of lines) { + if (line.startsWith('event: ')) { + eventType = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + dataLine = line.slice(6).trim(); + } + } + + if (dataLine === '[DONE]') { + this.push('data: [DONE]\n\n'); + continue; + } + + if (!dataLine) continue; + + let parsed: any; + try { + parsed = JSON.parse(dataLine); + } catch { + continue; + } + + if (eventType === 'response.created') { + const convId = parsed?.response?.conversation; + const requestId = parsed?.response?.id; + + if (convId && !conversationCaptured) { + conversationCaptured = true; + logger.info(`Captured conversation ID: ${convId}`); + sessionService + .updateSession(sessionId, userId, undefined, undefined, { + ...session.metadata, + conversation_id: convId, + }) + .catch((err: any) => + logger.error(`Failed to update session: ${err}`), + ); + } + + const legacy = { + event: 'start', + data: { conversation_id: convId, request_id: requestId }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } else if (eventType === 'response.output_text.delta') { + const legacy = { + event: 'token', + data: { token: parsed?.delta ?? '' }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } else if (eventType === 'response.completed') { + const usage = parsed?.response?.usage; + const legacy = { + event: 'end', + data: { + referenced_documents: [], + input_tokens: usage?.input_tokens, + output_tokens: usage?.output_tokens, + }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); + } + } + callback(); + }, + + flush(callback: any) { + if (buffer.trim()) { + const lines = buffer.split('\n'); + let dataLine = ''; for (const line of lines) { - if ( - line.startsWith('data: ') && - line.slice(6).trim() !== '[DONE]' - ) { - try { - const conversationId = JSON.parse(line.slice(6))?.response - ?.conversation; - if (conversationId) { - captured = true; - buffer = ''; - logger.info(`Captured conversation ID: ${conversationId}`); - - sessionService - .updateSession(sessionId, userId, undefined, undefined, { - ...session.metadata, - conversation_id: conversationId, - }) - .catch((err: any) => - logger.error(`Failed to update session: ${err}`), - ); - break; - } - } catch { - // Ignore parse errors for non-JSON SSE markers - } + if (line.startsWith('data: ')) { + dataLine = line.slice(6).trim(); } } + if (dataLine === '[DONE]') { + this.push('data: [DONE]\n\n'); + } } callback(); }, @@ -445,16 +508,9 @@ export async function createNotebooksRouter( if (response.body) { const body = Readable.fromWeb(response.body as any); - const stream = conversationId - ? body - : body.pipe( - createConversationIdCaptureTransform( - session, - sessionId, - userId, - ), - ); - stream.pipe(res); + body + .pipe(createResponsesApiTransform(session, sessionId, userId)) + .pipe(res); } break; } diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 8b5ed9cb5d3..96de6e83c53 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -1532,23 +1532,22 @@ export const LightspeedChat = ({ )} - {showChatPanel && ( - { - setIsMcpSettingsOpen(false); - onNewChat(); - handleSelectedModel(item); - }} - models={models} - isPinningChatsEnabled={isPinningChatsEnabled} - isModelSelectorDisabled={isSendButtonDisabled} - setDisplayMode={setDisplayMode} - displayMode={displayMode} - onPinnedChatsToggle={handlePinningChatsToggle} - onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} - /> - )} + { + setIsMcpSettingsOpen(false); + onNewChat(); + handleSelectedModel(item); + }} + models={models} + isPinningChatsEnabled={isPinningChatsEnabled} + isModelSelectorDisabled={isSendButtonDisabled} + hideModelSelector={showNotebooksPanel} + setDisplayMode={setDisplayMode} + displayMode={displayMode} + onPinnedChatsToggle={handlePinningChatsToggle} + onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} + /> {isFullscreenMode && ( <> diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx index 4e07c40d204..41dac085d43 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedChatBoxHeader.tsx @@ -51,6 +51,7 @@ type LightspeedChatBoxHeaderProps = { onPinnedChatsToggle: (state: boolean) => void; onMcpSettingsClick: () => void; isModelSelectorDisabled?: boolean; + hideModelSelector?: boolean; setDisplayMode: (mode: ChatbotDisplayMode) => void; }; @@ -85,6 +86,7 @@ export const LightspeedChatBoxHeader = ({ onPinnedChatsToggle, onMcpSettingsClick, isModelSelectorDisabled = false, + hideModelSelector = false, setDisplayMode, }: LightspeedChatBoxHeaderProps) => { const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false); @@ -136,35 +138,37 @@ export const LightspeedChatBoxHeader = ({ return ( - { - handleSelectedModel(value as string); - setIsOptionsMenuOpen(false); - }} - onOpenChange={isOpen => setIsOptionsMenuOpen(isOpen)} - popperProps={{ position: 'right' }} - shouldFocusToggleOnSelect - shouldFocusFirstItemOnOpen={false} - toggle={toggle} - isScrollable={isModelDropdownScrollable} - maxMenuHeight={isModelDropdownScrollable ? '240px' : undefined} - > - - {models.map(model => ( - - - {model.label} - - - ))} - - + {!hideModelSelector && ( + { + handleSelectedModel(value as string); + setIsOptionsMenuOpen(false); + }} + onOpenChange={isOpen => setIsOptionsMenuOpen(isOpen)} + popperProps={{ position: 'right' }} + shouldFocusToggleOnSelect + shouldFocusFirstItemOnOpen={false} + toggle={toggle} + isScrollable={isModelDropdownScrollable} + maxMenuHeight={isModelDropdownScrollable ? '240px' : undefined} + > + + {models.map(model => ( + + + {model.label} + + + ))} + + + )} Date: Sat, 25 Apr 2026 00:34:54 +0530 Subject: [PATCH 10/14] adding changeset Signed-off-by: its-mitesh-kumar --- .../.changeset/bright-notebooks-stream.md | 11 +++++++++++ .../lightspeed/.changeset/thin-humans-sparkle.md | 5 ----- .../src/components/notebooks/NotebookView.tsx | 14 +++++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 workspaces/lightspeed/.changeset/bright-notebooks-stream.md delete mode 100644 workspaces/lightspeed/.changeset/thin-humans-sparkle.md diff --git a/workspaces/lightspeed/.changeset/bright-notebooks-stream.md b/workspaces/lightspeed/.changeset/bright-notebooks-stream.md new file mode 100644 index 00000000000..68e48325744 --- /dev/null +++ b/workspaces/lightspeed/.changeset/bright-notebooks-stream.md @@ -0,0 +1,11 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': minor +'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch +--- + +Add notebook chat with streaming support, document management, and UI improvements. + +- Backend: add SSE transform to normalize Responses API format to legacy streaming format so notebook chat streams token-by-token like the chat tab. +- Frontend: add notebook chat view with conversation messages, document sidebar with per-document delete, and topic summary display. +- Fix stale document list when re-opening a notebook by setting query staleTime to 0. +- Hide model selector on the Notebooks tab while keeping the settings ellipsis menu visible. diff --git a/workspaces/lightspeed/.changeset/thin-humans-sparkle.md b/workspaces/lightspeed/.changeset/thin-humans-sparkle.md deleted file mode 100644 index 10eb057f5b5..00000000000 --- a/workspaces/lightspeed/.changeset/thin-humans-sparkle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@red-hat-developer-hub/backstage-plugin-lightspeed': minor ---- - -Add notebook creation and document upload flow with file type validation, overwrite confirmation, collapsible document sidebar, file type icons, i18n support, and unit tests. diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index e4b36f78a83..018546bd984 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -173,6 +173,16 @@ const useStyles = makeStyles(theme => ({ fullWidth: { maxWidth: 'unset', }, + footerAlignedAlert: { + maxWidth: '60rem', + width: '90%', + margin: '0 auto', + padding: `0 0 ${theme.spacing(1)}px`, + }, + alertFullWidth: { + maxWidth: 'unset', + width: '95%', + }, footerFullWidth: { '&>.pf-chatbot__footer-container': { width: '95% !important', @@ -624,7 +634,9 @@ export const NotebookView = ({
{renderMainContent()}
{!hasDocuments && ( -
+
{t('disclaimer.withoutValidation')} From 43aee2da27fdc3782cc955f000433c34e1b9c83e Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Sat, 25 Apr 2026 12:52:03 +0530 Subject: [PATCH 11/14] updating delete functionality Signed-off-by: its-mitesh-kumar --- .../lightspeed/src/components/notebooks/NotebookView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 018546bd984..39af61b7a44 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -494,7 +494,7 @@ export const NotebookView = ({ ); const renderMainContent = () => { - if (!hasDocuments) { + if (!hasDocuments && messages.length === 0) { return ; } if (messages.length > 0) { @@ -633,7 +633,7 @@ export const NotebookView = ({
{renderMainContent()}
- {!hasDocuments && ( + {!hasDocuments && messages.length === 0 && (
From 89c7ae7dbce0c4d7493fc956155c14cfe426b7a9 Mon Sep 17 00:00:00 2001 From: its-mitesh-kumar Date: Mon, 27 Apr 2026 19:35:26 +0530 Subject: [PATCH 12/14] fixing width Signed-off-by: its-mitesh-kumar --- .../src/components/notebooks/NotebookView.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 39af61b7a44..097772b55d0 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -174,16 +174,12 @@ const useStyles = makeStyles(theme => ({ maxWidth: 'unset', }, footerAlignedAlert: { - maxWidth: '60rem', - width: '90%', - margin: '0 auto', - padding: `0 0 ${theme.spacing(1)}px`, - }, - alertFullWidth: { maxWidth: 'unset', width: '95%', + margin: '0 auto', + padding: `0 0 ${theme.spacing(1)}px`, }, - footerFullWidth: { + footer: { '&>.pf-chatbot__footer-container': { width: '95% !important', maxWidth: 'unset !important', @@ -634,20 +630,14 @@ export const NotebookView = ({
{renderMainContent()}
{!hasDocuments && messages.length === 0 && ( -
+
{t('disclaimer.withoutValidation')}
)} - + Date: Mon, 27 Apr 2026 20:19:22 +0530 Subject: [PATCH 13/14] improving spacing Signed-off-by: its-mitesh-kumar --- .../src/components/notebooks/NotebookView.tsx | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx index 097772b55d0..b498d20cd65 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx @@ -123,10 +123,10 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', }, alertContainer: { - width: '100%', - maxWidth: 816, + width: '95%', + maxWidth: 'unset', margin: '0 auto', - padding: `0 ${theme.spacing(3)}px ${theme.spacing(1)}px`, + padding: `0 0 ${theme.spacing(1)}px`, }, toastAlertGroup: { '--pf-v6-c-alert-group--m-toast--InsetInlineEnd': `${theme.spacing(2.5)}px`, @@ -147,10 +147,10 @@ const useStyles = makeStyles(theme => ({ overflow: 'auto', }, notebookContentArea: { - width: '100%', - maxWidth: 816, - margin: '0 auto', - padding: `0 ${theme.spacing(3)}px`, + width: '95%', + maxWidth: 'unset', + margin: `${theme.spacing(3)}px auto 0 auto`, + padding: 0, }, notebookHeading: { fontSize: '2rem', @@ -165,13 +165,9 @@ const useStyles = makeStyles(theme => ({ paddingTop: theme.spacing(0.5), }, promptSuggestions: { - width: '100%', - maxWidth: 816, - margin: '0 auto', - padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, - }, - fullWidth: { + width: '95%', maxWidth: 'unset', + margin: '0 auto', }, footerAlignedAlert: { maxWidth: 'unset', @@ -512,16 +508,12 @@ export const NotebookView = ({ } return (
-
+
{t('disclaimer.withoutValidation')}
-
+
{notebookName} @@ -532,9 +524,7 @@ export const NotebookView = ({ )}
{welcomePrompts.length > 0 && ( -
+
Date: Mon, 27 Apr 2026 22:01:39 +0530 Subject: [PATCH 14/14] update doc_url Signed-off-by: its-mitesh-kumar --- .../plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx index 8b5629d24f8..497718f682f 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/utils/lightspeed-chatbox-utils.tsx @@ -164,7 +164,7 @@ export const transformDocumentsToSources = ( body: doc.doc_description, title: doc.doc_title, link: doc?.doc_url, - isExternal: true, + isExternal: !!doc?.doc_url, })), }; };