diff --git a/workspaces/lightspeed/.changeset/notebook-upload-modal-ux.md b/workspaces/lightspeed/.changeset/notebook-upload-modal-ux.md
new file mode 100644
index 00000000000..f663adee8c7
--- /dev/null
+++ b/workspaces/lightspeed/.changeset/notebook-upload-modal-ux.md
@@ -0,0 +1,5 @@
+---
+'@red-hat-developer-hub/backstage-plugin-lightspeed': minor
+---
+
+Improved notebook upload modal and MessageBar UX.
diff --git a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md
index f954c7c2b76..4bb031f10d4 100644
--- a/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md
+++ b/workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md
@@ -230,6 +230,7 @@ export const lightspeedTranslationRef: TranslationRef<
readonly 'notebook.view.upload.heading': string;
readonly 'notebook.view.upload.action': string;
readonly 'notebook.view.input.placeholder': string;
+ readonly 'notebook.view.input.disabledTooltip': string;
readonly 'notebook.view.sidebar.collapse': string;
readonly 'notebook.view.sidebar.expand': string;
readonly 'notebook.view.sidebar.resize': string;
@@ -241,6 +242,9 @@ export const lightspeedTranslationRef: TranslationRef<
readonly 'notebook.upload.modal.browseButton': string;
readonly 'notebook.upload.modal.separator': string;
readonly 'notebook.upload.modal.infoText': string;
+ readonly 'notebook.upload.modal.selectedFiles': string;
+ readonly 'notebook.upload.modal.addButton': string;
+ readonly 'notebook.upload.modal.removeFile': string;
readonly 'notebook.upload.error.unsupportedType': string;
readonly 'notebook.upload.error.fileTooLarge': string;
readonly 'notebook.upload.error.tooManyFiles': string;
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/AddDocumentModal.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/AddDocumentModal.test.tsx
new file mode 100644
index 00000000000..a2e0e29bf1f
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/AddDocumentModal.test.tsx
@@ -0,0 +1,281 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+
+import { mockUseTranslation } from '../../test-utils/mockTranslations';
+import { AddDocumentModal } from '../notebooks/AddDocumentModal';
+
+jest.mock('../../hooks/useTranslation', () => ({
+ useTranslation: jest.fn(() => mockUseTranslation()),
+}));
+
+const mockMutateAsync = jest.fn();
+jest.mock('../../hooks/notebooks/useUploadDocument', () => ({
+ useUploadDocument: () => ({
+ mutateAsync: mockMutateAsync,
+ }),
+}));
+
+describe('AddDocumentModal', () => {
+ const defaultProps = {
+ isOpen: true,
+ onClose: jest.fn(),
+ sessionId: 'test-session-id',
+ existingDocumentNames: [],
+ onFilesUploading: jest.fn(),
+ onUploadStarted: jest.fn(),
+ onUploadFailed: jest.fn(),
+ onDuplicatesFound: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockMutateAsync.mockResolvedValue({ document_id: 'test-doc-id' });
+ });
+
+ it('should render the modal when open', () => {
+ render();
+
+ expect(screen.getByText('Add a document to Notebook')).toBeInTheDocument();
+ expect(screen.getByText('Drag and drop files here')).toBeInTheDocument();
+ });
+
+ it('should not render when isOpen is false', () => {
+ render();
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('should render Cancel and Add buttons', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Add (0)' })).toBeInTheDocument();
+ });
+
+ it('should have Add button disabled when no files selected', () => {
+ render();
+
+ const addButton = screen.getByRole('button', { name: 'Add (0)' });
+ expect(addButton).toBeDisabled();
+ });
+
+ it('should call onClose when Cancel button is clicked', () => {
+ render();
+
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+ fireEvent.click(cancelButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onClose when close icon is clicked', () => {
+ render();
+
+ const closeButton = screen.getByRole('button', { name: 'Close' });
+ fireEvent.click(closeButton);
+
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should display file list when files are dropped', async () => {
+ render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('test-file.txt')).toBeInTheDocument();
+ });
+ });
+
+ it('should update Add button count when files are selected', async () => {
+ render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: 'Add (1)' }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('should not auto-close modal after file drop', async () => {
+ render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('test-file.txt')).toBeInTheDocument();
+ });
+
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+ });
+
+ it('should trigger upload and close when Add button is clicked', async () => {
+ render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: 'Add (1)' }),
+ ).not.toBeDisabled();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add (1)' }));
+
+ await waitFor(() => {
+ expect(defaultProps.onFilesUploading).toHaveBeenCalledWith([file]);
+ expect(mockMutateAsync).toHaveBeenCalledWith({
+ sessionId: 'test-session-id',
+ file,
+ });
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('should allow removing files from the list', async () => {
+ render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('test-file.txt')).toBeInTheDocument();
+ });
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove test-file.txt',
+ });
+ fireEvent.click(removeButton);
+
+ await waitFor(() => {
+ expect(screen.queryByText('test-file.txt')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Add (0)' })).toBeDisabled();
+ });
+ });
+
+ it('should clear selected files when modal is closed', async () => {
+ const { rerender } = render();
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const file = new File(['content'], 'test-file.txt', { type: 'text/plain' });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [file],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('test-file.txt')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ rerender();
+
+ expect(screen.queryByText('test-file.txt')).not.toBeInTheDocument();
+ });
+
+ it('should call onDuplicatesFound for files that already exist', async () => {
+ render(
+ ,
+ );
+
+ const dropzone = screen
+ .getByText('Drag and drop files here')
+ .closest('div');
+ const existingFile = new File(['content'], 'existing-file.txt', {
+ type: 'text/plain',
+ });
+ const newFile = new File(['content'], 'new-file.txt', {
+ type: 'text/plain',
+ });
+
+ fireEvent.drop(dropzone!, {
+ dataTransfer: {
+ files: [existingFile, newFile],
+ types: ['Files'],
+ },
+ });
+
+ await waitFor(() => {
+ expect(defaultProps.onDuplicatesFound).toHaveBeenCalledWith([
+ existingFile,
+ ]);
+ expect(screen.getByText('new-file.txt')).toBeInTheDocument();
+ expect(screen.queryByText('existing-file.txt')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileListItem.test.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileListItem.test.tsx
new file mode 100644
index 00000000000..4438fb7f756
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/FileListItem.test.tsx
@@ -0,0 +1,133 @@
+/*
+ * 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 { FileListItem } from '../notebooks/FileListItem';
+
+describe('FileListItem', () => {
+ const createFile = (
+ name: string,
+ size: number,
+ type: string = 'text/plain',
+ ) => new File(['x'.repeat(size)], name, { type });
+
+ const defaultProps = {
+ file: createFile('test-file.txt', 1024),
+ onRemove: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render the file name', () => {
+ render();
+
+ expect(screen.getByText('test-file.txt')).toBeInTheDocument();
+ });
+
+ it('should render the file type icon', () => {
+ render();
+
+ expect(screen.getByText('txt')).toBeInTheDocument();
+ });
+
+ it('should render the file size in KB', () => {
+ render();
+
+ expect(screen.getByText('1 KB')).toBeInTheDocument();
+ });
+
+ it('should render the file size in MB for larger files', () => {
+ const largeFile = createFile('large-file.pdf', 5 * 1024 * 1024);
+ render();
+
+ expect(screen.getByText('5 MB')).toBeInTheDocument();
+ });
+
+ it('should render 0 B for empty files', () => {
+ const emptyFile = createFile('empty.txt', 0);
+ render();
+
+ expect(screen.getByText('0 B')).toBeInTheDocument();
+ });
+
+ it('should call onRemove when remove button is clicked', () => {
+ render();
+
+ const removeButton = screen.getByRole('button', { name: 'Remove file' });
+ fireEvent.click(removeButton);
+
+ expect(defaultProps.onRemove).toHaveBeenCalledTimes(1);
+ });
+
+ it('should use custom aria-label for remove button', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole('button', { name: 'Remove test-file.txt' }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render different file type icons based on extension', () => {
+ const pdfFile = createFile('document.pdf', 1024);
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText('pdf')).toBeInTheDocument();
+
+ const yamlFile = createFile('config.yaml', 1024);
+ rerender();
+
+ expect(screen.getByText('yaml')).toBeInTheDocument();
+ });
+
+ it('should show title tooltip for long file names', () => {
+ const longNameFile = createFile(
+ 'this-is-a-very-long-filename-that-should-be-truncated.txt',
+ 1024,
+ );
+ render();
+
+ const fileNameElement = screen.getByTitle(
+ 'this-is-a-very-long-filename-that-should-be-truncated.txt',
+ );
+ expect(fileNameElement).toBeInTheDocument();
+ });
+
+ it('should truncate long file names while preserving extension', () => {
+ const longNameFile = createFile(
+ 'this-is-a-very-long-filename-that-should-be-truncated.txt',
+ 1024,
+ );
+ render();
+
+ expect(
+ screen.getByText('this-is-a-very-long-fil....txt'),
+ ).toBeInTheDocument();
+ });
+
+ it('should not truncate short file names', () => {
+ const shortNameFile = createFile('short-name.pdf', 1024);
+ render();
+
+ expect(screen.getByText('short-name.pdf')).toBeInTheDocument();
+ });
+});
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx
index cfb902cc999..e35efd1b319 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/AddDocumentModal.tsx
@@ -20,7 +20,10 @@ import { FileRejection } from 'react-dropzone';
import { makeStyles } from '@material-ui/core/styles';
import CloseIcon from '@mui/icons-material/Close';
import Alert from '@mui/material/Alert';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
@@ -38,6 +41,7 @@ import {
getNotebookAcceptedFileTypes,
validateFiles,
} from '../../utils/notebook-upload-utils';
+import { FileListItem } from './FileListItem';
const useStyles = makeStyles(theme => ({
dialogPaper: {
@@ -76,6 +80,32 @@ const useStyles = makeStyles(theme => ({
'color-mix(in srgb, var(--pf-t--global--color--brand--default) 10%, transparent)',
},
},
+ fileListContainer: {
+ marginTop: theme.spacing(2),
+ maxHeight: 200,
+ overflowY: 'auto',
+ },
+ fileListHeader: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: theme.spacing(1),
+ },
+ fileCount: {
+ fontSize: '0.875rem',
+ color: theme.palette.text.secondary,
+ },
+ dialogActions: {
+ padding: '16px 24px',
+ justifyContent: 'flex-end',
+ gap: theme.spacing(1),
+ },
+ addButton: {
+ textTransform: 'none',
+ },
+ cancelButton: {
+ textTransform: 'none',
+ },
}));
type AddDocumentModalProps = {
@@ -103,14 +133,16 @@ export const AddDocumentModal = ({
const { t } = useTranslation();
const uploadMutation = useUploadDocument();
const [validationErrors, setValidationErrors] = useState([]);
+ const [selectedFiles, setSelectedFiles] = useState([]);
+
+ const totalExistingAndSelected =
+ existingDocumentNames.length + selectedFiles.length;
+ const remainingSlots = NOTEBOOK_MAX_FILES - totalExistingAndSelected;
const handleFileDrop = (_event: unknown, files: File[]) => {
setValidationErrors([]);
- const { valid, errors } = validateFiles(
- files,
- existingDocumentNames.length,
- );
+ const { valid, errors } = validateFiles(files, totalExistingAndSelected);
if (errors.length > 0) {
setValidationErrors(errors);
@@ -119,31 +151,47 @@ export const AddDocumentModal = ({
if (valid.length === 0) return;
- const existingNamesSet = new Set(existingDocumentNames);
+ const existingNamesSet = new Set([
+ ...existingDocumentNames,
+ ...selectedFiles.map(f => f.name),
+ ]);
const newFiles = valid.filter(f => !existingNamesSet.has(f.name));
- const duplicateFiles = valid.filter(f => existingNamesSet.has(f.name));
+ const duplicateFiles = valid.filter(f =>
+ existingDocumentNames.includes(f.name),
+ );
+
+ if (duplicateFiles.length > 0) {
+ onDuplicatesFound?.(duplicateFiles);
+ }
if (newFiles.length > 0) {
- onFilesUploading?.(newFiles);
- for (const file of newFiles) {
- uploadMutation
- .mutateAsync({ sessionId, file })
- .then(data => {
- onUploadStarted?.({
- fileName: file.name,
- documentId: data.document_id,
- });
- })
- .catch(() => {
- onUploadFailed?.(file.name);
- });
- }
+ setSelectedFiles(prev => [...prev, ...newFiles]);
}
+ };
- if (duplicateFiles.length > 0) {
- onDuplicatesFound?.(duplicateFiles);
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index));
+ };
+
+ const handleAddFiles = () => {
+ if (selectedFiles.length === 0) return;
+
+ onFilesUploading?.(selectedFiles);
+ for (const file of selectedFiles) {
+ uploadMutation
+ .mutateAsync({ sessionId, file })
+ .then(data => {
+ onUploadStarted?.({
+ fileName: file.name,
+ documentId: data.document_id,
+ });
+ })
+ .catch(() => {
+ onUploadFailed?.(file.name);
+ });
}
+ setSelectedFiles([]);
setValidationErrors([]);
onClose();
};
@@ -158,6 +206,7 @@ export const AddDocumentModal = ({
};
const handleClose = () => {
+ setSelectedFiles([]);
setValidationErrors([]);
onClose();
};
@@ -174,6 +223,8 @@ export const AddDocumentModal = ({
{t('notebook.upload.modal.title')}
+ {selectedFiles.length > 0 &&
+ ` (${selectedFiles.length}/${NOTEBOOK_MAX_FILES - existingDocumentNames.length})`}
)}
-
- }
- titleText={t('notebook.upload.modal.dragDropTitle')}
- titleTextSeparator={t('notebook.upload.modal.separator')}
- infoText={t('notebook.upload.modal.infoText')}
- browseButtonText={t('notebook.upload.modal.browseButton')}
- />
-
+ {remainingSlots > 0 && (
+
+ }
+ titleText={t('notebook.upload.modal.dragDropTitle')}
+ titleTextSeparator={t('notebook.upload.modal.separator')}
+ infoText={t('notebook.upload.modal.infoText')}
+ browseButtonText={t('notebook.upload.modal.browseButton')}
+ />
+
+ )}
+
+ {selectedFiles.length > 0 && (
+
+
+
+ {(t as Function)('notebook.upload.modal.selectedFiles', {
+ count: selectedFiles.length,
+ max: NOTEBOOK_MAX_FILES - existingDocumentNames.length,
+ })}
+
+
+ {selectedFiles.map((file, index) => (
+ handleRemoveFile(index)}
+ removeAriaLabel={(t as Function)(
+ 'notebook.upload.modal.removeFile',
+ {
+ fileName: file.name,
+ },
+ )}
+ />
+ ))}
+
+ )}
+
+
+
+
+
);
};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileListItem.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileListItem.tsx
new file mode 100644
index 00000000000..d6303b7d9fd
--- /dev/null
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/FileListItem.tsx
@@ -0,0 +1,125 @@
+/*
+ * 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 Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+
+import { FileTypeIcon } from './FileTypeIcon';
+
+const useStyles = makeStyles(theme => ({
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '8px 12px',
+ borderRadius: 8,
+ backgroundColor: theme.palette.type === 'dark' ? '#2a2a2a' : '#f5f5f5',
+ marginBottom: 8,
+ '&:last-child': {
+ marginBottom: 0,
+ },
+ },
+ fileInfo: {
+ display: 'flex',
+ alignItems: 'center',
+ flex: 1,
+ minWidth: 0,
+ gap: 12,
+ },
+ fileName: {
+ flex: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ fontSize: '0.875rem',
+ },
+ fileSize: {
+ color: theme.palette.text.secondary,
+ fontSize: '0.75rem',
+ flexShrink: 0,
+ marginRight: 8,
+ },
+ removeButton: {
+ padding: 4,
+ color: theme.palette.grey[600],
+ '&:hover': {
+ color: theme.palette.error.main,
+ },
+ },
+}));
+
+type FileListItemProps = {
+ file: File;
+ onRemove: () => void;
+ removeAriaLabel?: string;
+};
+
+const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+};
+
+const MAX_FILENAME_LENGTH = 30;
+
+const truncateFileName = (fileName: string, maxLength: number): string => {
+ if (fileName.length <= maxLength) return fileName;
+
+ const lastDot = fileName.lastIndexOf('.');
+ const extension = lastDot >= 0 ? fileName.slice(lastDot) : '';
+ const baseName = lastDot >= 0 ? fileName.slice(0, lastDot) : fileName;
+
+ const availableLength = maxLength - extension.length - 3;
+ if (availableLength <= 0) return fileName;
+
+ return `${baseName.slice(0, availableLength)}...${extension}`;
+};
+
+export const FileListItem = ({
+ file,
+ onRemove,
+ removeAriaLabel = 'Remove file',
+}: FileListItemProps) => {
+ const classes = useStyles();
+ const displayName = truncateFileName(file.name, MAX_FILENAME_LENGTH);
+
+ return (
+
+
+
+
+ {displayName}
+
+
+
+ {formatFileSize(file.size)}
+
+
+
+
+
+ );
+};
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx
index b498d20cd65..eab77df495d 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/NotebookView.tsx
@@ -551,6 +551,8 @@ export const NotebookView = ({
variant={AlertVariant[variant ?? 'success']}
title={title}
className={classes.toastAlert}
+ timeout={2000}
+ onTimeout={() => handleRemoveToastAlert(key as React.Key)}
actionClose={
-
+ {documents.length === 0 ? (
+
+
+
+
+
+ ) : (
+
+ )}
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx
index 8e52600a287..a4f2fe7da31 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx
+++ b/workspaces/lightspeed/plugins/lightspeed/src/components/notebooks/OverwriteConfirmModal.tsx
@@ -63,6 +63,7 @@ const useStyles = makeStyles(theme => ({
padding: `${theme.spacing(2)}px 0`,
borderBottom:
'1px solid var(--pf-t--global--border--color--default, #c7c7c7)',
+ cursor: 'pointer',
},
fileName: {
flex: 1,
diff --git a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
index 5f494fc6dbd..216af6177f0 100644
--- a/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
+++ b/workspaces/lightspeed/plugins/lightspeed/src/translations/ref.ts
@@ -61,6 +61,8 @@ export const lightspeedMessages = {
'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.input.disabledTooltip':
+ 'Select at least one loaded resource to start chatting',
'notebook.view.sidebar.collapse': 'Collapse sidebar',
'notebook.view.sidebar.expand': 'Expand sidebar',
'notebook.view.sidebar.resize': 'Resize sidebar',
@@ -75,6 +77,9 @@ export const lightspeedMessages = {
'notebook.upload.modal.separator': 'or',
'notebook.upload.modal.infoText':
'Accepted file types: .md, .txt, .pdf, .json, .yaml, .log',
+ 'notebook.upload.modal.selectedFiles': '{{count}} of {{max}} files selected',
+ 'notebook.upload.modal.addButton': 'Add ({{count}})',
+ 'notebook.upload.modal.removeFile': 'Remove {{fileName}}',
'notebook.upload.error.unsupportedType':
'Upload error: Unsupported file type(s) found. Please upload only supported file types.',
'notebook.upload.error.fileTooLarge':