diff --git a/workspaces/intelligent-assistant/.changeset/bright-tigers-dance.md b/workspaces/intelligent-assistant/.changeset/bright-tigers-dance.md new file mode 100644 index 00000000000..be494c67173 --- /dev/null +++ b/workspaces/intelligent-assistant/.changeset/bright-tigers-dance.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant': patch +--- + +Improve notebook UI: enhance AddDocumentModal, OverwriteConfirmModal, NotebookCard, and NotebookView components with better UX text, document count formatting, and fix translation type errors diff --git a/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks.test.ts b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks.test.ts index b0efce6648c..c8abab62f0a 100644 --- a/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks.test.ts +++ b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks.test.ts @@ -100,7 +100,7 @@ test.describe('Intelligent assistant notebooks', () => { await uploadModal.selectFilesViaBrowsePicker( notebookElevenFileStagingPaths(), ); - await expect(uploadModal.dialog().getByRole('alert')).toContainText( + await expect(uploadModal.errorAlert()).toContainText( substituteNotebookTemplate( translations['notebook.upload.error.tooManyFiles'], { max: NOTEBOOK_SESSION_MAX_DOCUMENTS }, @@ -115,7 +115,7 @@ test.describe('Intelligent assistant notebooks', () => { await uploadModal.selectFilesViaBrowsePicker([ notebookUnsupportedTypeFixturePath(), ]); - await expect(uploadModal.dialog().getByRole('alert')).toContainText( + await expect(uploadModal.errorAlert()).toContainText( translations['notebook.upload.error.unsupportedType'], ); await uploadModal.clickCancel(); @@ -136,19 +136,27 @@ test.describe('Intelligent assistant notebooks', () => { await notebooks.clickOpenUploadDocumentModal(); uploadModal = notebooks.uploadDocumentModal(); await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); const overwriteModal = notebooks.notebookOverwriteConfirmModal(); await overwriteModal.expectDialogVisible(); await overwriteModal.expectListedOverwriteFile(fileName); - await overwriteModal.clickCancel(); - await sharedPage.waitForTimeout(200); + await overwriteModal.clickBack(); await uploadModal.clickCancel(); await notebooks.deleteFirstListedDocumentFromSidebarOverflowMenu(); await notebooks.expectNotebookEditorUploadResourceButtonVisible(); }); - test('grid: close editor, rename, delete', async () => { + test('grid: close editor, rename, delete', async ({}, testInfo) => { + const { absolutePath } = localeNotebookUpload1Path(testInfo.project.name); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await sharedPage.waitForTimeout(1000); + const untitledBefore = await notebooks.untitledNotebookCards().count(); await notebooks.clickCloseNotebookEditor(); @@ -157,7 +165,7 @@ test.describe('Intelligent assistant notebooks', () => { await expect(notebooks.newestUntitledNotebookCard()).toBeVisible(); await notebooks.expectNotebookListShowsDocumentCountSummaryAndUpdatedToday( - 0, + 1, ); await notebooks @@ -188,8 +196,19 @@ test.describe('Intelligent assistant notebooks', () => { await notebooks.expectUntitledNotebookCardCount(untitledBefore); }); - test('grid: click card title triggers inline rename', async () => { + test('grid: click card title triggers inline rename', async ({}, testInfo) => { + const { absolutePath, fileName } = localeNotebookUpload1Path( + testInfo.project.name, + ); + await notebooks.clickPrimaryNotebookCreate(); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await notebooks.expectDocumentFileListedInSidebar(fileName); + await notebooks.clickCloseNotebookEditor(); const card = notebooks.newestUntitledNotebookCard(); @@ -215,8 +234,19 @@ test.describe('Intelligent assistant notebooks', () => { await notebooks.expectNotebookCardAbsent(newName); }); - test('grid: Escape cancels inline rename', async () => { + test('grid: Escape cancels inline rename', async ({}, testInfo) => { + const { absolutePath, fileName } = localeNotebookUpload1Path( + testInfo.project.name, + ); + await notebooks.clickPrimaryNotebookCreate(); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await notebooks.expectDocumentFileListedInSidebar(fileName); + await notebooks.clickCloseNotebookEditor(); const card = notebooks.newestUntitledNotebookCard(); @@ -245,8 +275,19 @@ test.describe('Intelligent assistant notebooks', () => { await confirmDelete.confirmDeletion(); }); - test('grid: blur saves inline rename', async () => { + test('grid: blur saves inline rename', async ({}, testInfo) => { + const { absolutePath, fileName } = localeNotebookUpload1Path( + testInfo.project.name, + ); + await notebooks.clickPrimaryNotebookCreate(); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await notebooks.expectDocumentFileListedInSidebar(fileName); + await notebooks.clickCloseNotebookEditor(); const card = notebooks.newestUntitledNotebookCard(); @@ -273,8 +314,19 @@ test.describe('Intelligent assistant notebooks', () => { await notebooks.expectNotebookCardAbsent(newName); }); - test('grid: empty or unchanged name cancels rename', async () => { + test('grid: empty or unchanged name cancels rename', async ({}, testInfo) => { + const { absolutePath, fileName } = localeNotebookUpload1Path( + testInfo.project.name, + ); + await notebooks.clickPrimaryNotebookCreate(); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await notebooks.expectDocumentFileListedInSidebar(fileName); + await notebooks.clickCloseNotebookEditor(); const card = notebooks.newestUntitledNotebookCard(); @@ -339,4 +391,47 @@ test.describe('Intelligent assistant notebooks', () => { await confirmDelete.confirmDeletion(); await notebooks.expectNotebookCardAbsent(newName); }); + + test('auto-delete: empty untitled notebook is discarded on close', async () => { + await notebooks.gotoFullscreenNotebooksTab(); + const cardsBefore = await notebooks.untitledNotebookCards().count(); + + await notebooks.clickCreateNotebookFromEmptyList(); + await expect(sharedPage).toHaveURL(NOTEBOOK_EDITOR_URL_RE); + + await notebooks.clickCloseNotebookEditor(); + + await notebooks.expectUntitledNotebookCardCount(cardsBefore); + }); + + test('auto-delete: notebook with uploaded file persists on close', async ({}, testInfo) => { + const { absolutePath, fileName } = localeNotebookUpload1Path( + testInfo.project.name, + ); + const cardsBefore = await notebooks.untitledNotebookCards().count(); + + await notebooks.clickCreateNotebookFromEmptyList(); + await expect(sharedPage).toHaveURL(NOTEBOOK_EDITOR_URL_RE); + + await notebooks.clickOpenUploadDocumentModal(); + const uploadModal = notebooks.uploadDocumentModal(); + await uploadModal.selectFilesViaBrowsePicker([absolutePath]); + await uploadModal.clickAddFilesForStagedCount(1); + await notebooks.expectDocumentFileListedInSidebar(fileName); + await sharedPage.waitForTimeout(2000); + + await notebooks.clickCloseNotebookEditor(); + + await notebooks.expectUntitledNotebookCardCount(cardsBefore + 1); + + await notebooks + .notebookCardOverflowMenuButton(notebooks.newestUntitledNotebookCard()) + .click(); + await notebooks.deleteNotebookOverflowMenuItem().click(); + const confirmDelete = notebooks.notebookDeleteConfirmationDialog( + NOTEBOOK_UNTITLED_GRID_NAME, + ); + await confirmDelete.confirmDeletion(); + await notebooks.expectUntitledNotebookCardCount(cardsBefore); + }); }); diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookAddDocumentModalPage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookAddDocumentModalPage.ts index 69f75276fba..5f6e8f8ede9 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookAddDocumentModalPage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookAddDocumentModalPage.ts @@ -44,32 +44,24 @@ export class NotebookAddDocumentModalPage { ); } - separatorBetweenDragZoneAndBrowse(): Locator { - return this.dialog().getByText(this.t['notebook.upload.modal.separator'], { - exact: true, - }); - } - - browseFilesButton(): Locator { - return this.dialog().getByRole('button', { - name: this.t['notebook.upload.modal.browseButton'], - exact: true, - }); + supportedFormatsLabel(): Locator { + return this.dialog().getByText( + this.t['notebook.upload.modal.supportedFormats'], + ); } - acceptedFileTypesParagraph(): Locator { - return this.dialog().getByText(this.t['notebook.upload.modal.infoText'], { - exact: true, - }); + maxFileSizeText(): Locator { + return this.dialog().getByText(this.t['notebook.upload.modal.maxFileSize']); } addFilesButton(stagedCount: number): Locator { - const label = substituteNotebookTemplate( - this.t['notebook.upload.modal.addButton'], - { - count: stagedCount, - }, - ); + const label = + stagedCount > 0 + ? substituteNotebookTemplate( + this.t['notebook.upload.modal.addButton'], + { count: stagedCount }, + ) + : this.t['notebook.upload.modal.addButtonEmpty']; return this.dialog().getByRole('button', { name: label }); } @@ -82,9 +74,8 @@ export class NotebookAddDocumentModalPage { /** Drop-zone copy, “or”, browse button, accepted file types paragraph. */ async expectUploadAreaFullyDescribed(): Promise { await expect(this.dragAndDropInstructions()).toBeVisible(); - await expect(this.separatorBetweenDragZoneAndBrowse()).toBeVisible(); - await expect(this.browseFilesButton()).toBeVisible(); - await expect(this.acceptedFileTypesParagraph()).toBeVisible(); + await expect(this.supportedFormatsLabel()).toBeVisible(); + await expect(this.maxFileSizeText()).toBeVisible(); } async expectModalTitleBarMatchesAriaSnapshot(): Promise { @@ -102,7 +93,7 @@ export class NotebookAddDocumentModalPage { async selectFilesViaBrowsePicker(filePaths: string[]): Promise { const [fileChooser] = await Promise.all([ this.page.waitForEvent('filechooser'), - this.browseFilesButton().click(), + this.dragAndDropInstructions().click(), ]); await fileChooser.setFiles(filePaths); } @@ -132,7 +123,7 @@ export class NotebookAddDocumentModalPage { } errorAlert(): Locator { - return this.dialog().getByRole('alert'); + return this.dialog().locator('[data-ouia-component-type="PF6/Alert"]'); } async expectValidationAlertsInclude(text: string): Promise { diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookOverwriteConfirmModalPage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookOverwriteConfirmModalPage.ts index 7ac948257e2..4a359548a75 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookOverwriteConfirmModalPage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookOverwriteConfirmModalPage.ts @@ -17,9 +17,10 @@ import { expect, type Locator, type Page } from '@playwright/test'; import type { LightspeedMessages } from '../utils/translations'; +import { substituteNotebookTemplate } from '../utils/notebookTranslation'; /** - * “Overwrite Files?” confirmation when staging a file whose name already exists in the notebook (`OverwriteConfirmModal.tsx`). + * "File already exists" confirmation when uploading a file whose name already exists in the notebook (`OverwriteConfirmModal.tsx`). */ export class NotebookOverwriteConfirmModalPage { constructor( @@ -28,7 +29,6 @@ export class NotebookOverwriteConfirmModalPage { ) {} dialog(): Locator { - /** MUI dialogs may not use the plain title as the whole accessible name across engines. */ return this.page.getByRole('dialog').filter({ has: this.page.getByRole('heading', { name: this.t['notebook.overwrite.modal.title'], @@ -39,16 +39,14 @@ export class NotebookOverwriteConfirmModalPage { async expectDialogVisible(timeout = 15_000): Promise { await expect(this.dialog()).toBeVisible({ timeout }); - await expect( - this.dialog().getByText(this.t['notebook.overwrite.modal.description'], { - exact: true, - }), - ).toBeVisible(); } - async clickCancel(): Promise { + async clickBack(): Promise { await this.dialog() - .getByRole('button', { name: this.t['common.cancel'], exact: true }) + .getByRole('button', { + name: this.t['notebook.overwrite.modal.back'], + exact: true, + }) .click(); } @@ -58,12 +56,19 @@ export class NotebookOverwriteConfirmModalPage { ).toBeVisible(); } - async clickOverwrite(): Promise { + uploadButtonPattern(): RegExp { + const escaped = substituteNotebookTemplate( + this.t['notebook.overwrite.modal.action'], + { count: '\\d+' }, + ) + .replace('(', '\\(') + .replace(')', '\\)'); + return new RegExp(escaped); + } + + async clickUpload(): Promise { await this.dialog() - .getByRole('button', { - name: this.t['notebook.overwrite.modal.action'], - exact: true, - }) + .getByRole('button', { name: this.uploadButtonPattern() }) .click(); } } diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts index 5a1c7dd7b18..e413028016d 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts @@ -17,6 +17,7 @@ import { expect, type Locator, type Page } from '@playwright/test'; import type { LightspeedMessages } from '../utils/translations'; +import { substituteNotebookTemplate } from '../utils/notebookTranslation'; import { openLightspeed } from '../utils/testHelper'; import { NotebookAddDocumentModalPage } from './NotebookAddDocumentModalPage'; @@ -341,12 +342,12 @@ export class NotebookSurfacePage { }); } - /** - * Shown on each card as count + plural label (same pattern as NotebookCard.tsx: - * `{ document_count } { t('notebooks.documents') }`, not `notebook.view.documents.count`). - */ formatNotebookCardDocumentsSummary(documentCount: number): string { - return `${documentCount} ${this.t['notebooks.documents']}`; + if (documentCount === 0) return this.t['notebooks.documents.none']; + if (documentCount === 1) return this.t['notebooks.documents.one']; + return substituteNotebookTemplate(this.t['notebooks.documents.other'], { + count: documentCount, + }); } async expectUntitledNotebookCardCount(expected: number): Promise { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md index f05c7c0c4d8..33dfb93f538 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/report-alpha.api.md @@ -51,6 +51,9 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'notebooks.empty.description': string; readonly 'notebooks.empty.action': string; readonly 'notebooks.documents': string; + readonly 'notebooks.documents.none': string; + readonly 'notebooks.documents.one': string; + readonly 'notebooks.documents.other': string; readonly 'notebooks.actions.rename': string; readonly 'notebooks.actions.delete': string; readonly 'notebooks.rename.inline.tooltip': string; @@ -86,15 +89,25 @@ export const intelligentAssistantTranslationRef: TranslationRef< readonly 'notebook.upload.modal.browseButton': string; readonly 'notebook.upload.modal.separator': string; readonly 'notebook.upload.modal.infoText': string; + readonly 'notebook.upload.modal.supportedFormats': string; + readonly 'notebook.upload.modal.maxFileSize': string; readonly 'notebook.upload.modal.selectedFiles': string; readonly 'notebook.upload.modal.addButton': string; + readonly 'notebook.upload.modal.addButtonEmpty': 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; readonly 'notebook.overwrite.modal.title': string; + readonly 'notebook.overwrite.modal.title.one': string; + readonly 'notebook.overwrite.modal.title.other': string; readonly 'notebook.overwrite.modal.description': string; + readonly 'notebook.overwrite.modal.description.one': string; + readonly 'notebook.overwrite.modal.description.other': string; + readonly 'notebook.overwrite.modal.replace': string; + readonly 'notebook.overwrite.modal.ignore': string; readonly 'notebook.overwrite.modal.action': string; + readonly 'notebook.overwrite.modal.back': string; readonly 'notebook.document.delete': string; readonly 'notebook.document.delete.title': string; readonly 'notebook.document.delete.description': string; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx index c275ca126de..5aa4554583d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -292,7 +292,7 @@ const useStyles = makeStyles(theme => ({ }, }, notebookCardHeader: { - padding: theme.spacing(2), + padding: theme.spacing(3), paddingBottom: 0, alignItems: 'center', }, @@ -301,16 +301,14 @@ const useStyles = makeStyles(theme => ({ marginTop: theme.spacing(1), }, notebookCardBody: { - padding: theme.spacing(2), - paddingTop: theme.spacing(1.5), + padding: theme.spacing(3), + paddingTop: theme.spacing(2), }, notebookDocuments: { paddingTop: theme.spacing(1), - paddingLeft: theme.spacing(2), }, notebookUpdated: { - paddingBottom: theme.spacing(5), - paddingLeft: theme.spacing(2), + paddingBottom: theme.spacing(3), paddingTop: theme.spacing(2), }, notebookTitle: { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/AddDocumentModal.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/AddDocumentModal.test.tsx index 2930e443c36..0725a64f485 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/AddDocumentModal.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/AddDocumentModal.test.tsx @@ -50,8 +50,10 @@ describe('AddDocumentModal', () => { it('should render the modal when open', () => { render(); - expect(screen.getByText('Add a resource to Notebook')).toBeInTheDocument(); - expect(screen.getByText('Drag and drop files here')).toBeInTheDocument(); + expect(screen.getByText('Add resources')).toBeInTheDocument(); + expect( + screen.getByText('Drag and drop files here, or click to browse'), + ).toBeInTheDocument(); }); it('should not render when isOpen is false', () => { @@ -64,13 +66,13 @@ describe('AddDocumentModal', () => { render(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add (0)' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); }); it('should have Add button disabled when no files selected', () => { render(); - const addButton = screen.getByRole('button', { name: 'Add (0)' }); + const addButton = screen.getByRole('button', { name: 'Add' }); expect(addButton).toBeDisabled(); }); @@ -96,7 +98,7 @@ describe('AddDocumentModal', () => { render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -116,7 +118,7 @@ describe('AddDocumentModal', () => { render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -138,7 +140,7 @@ describe('AddDocumentModal', () => { render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -160,7 +162,7 @@ describe('AddDocumentModal', () => { render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -193,7 +195,7 @@ describe('AddDocumentModal', () => { render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -215,7 +217,7 @@ describe('AddDocumentModal', () => { await waitFor(() => { expect(screen.queryByText('test-file.txt')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Add (0)' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); }); }); @@ -223,7 +225,7 @@ describe('AddDocumentModal', () => { const { rerender } = render(); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const file = new File(['content'], 'test-file.txt', { type: 'text/plain' }); @@ -245,7 +247,23 @@ describe('AddDocumentModal', () => { expect(screen.queryByText('test-file.txt')).not.toBeInTheDocument(); }); - it('should call onDuplicatesFound for files that already exist', async () => { + it('should show disabled dropzone with tooltip when file limit is reached', () => { + const existingNames = Array.from({ length: 10 }, (_, i) => `file-${i}.txt`); + render( + , + ); + + expect( + screen.getByText('Drag and drop files here, or click to browse'), + ).toBeInTheDocument(); + expect(screen.getByText('Maximum file size is 25 MB.')).toBeInTheDocument(); + expect(screen.getByText('Supported formats:')).toBeInTheDocument(); + }); + + it('should call onDuplicatesFound when Add is clicked with duplicate files', async () => { render( { ); const dropzone = screen - .getByText('Drag and drop files here') + .getByText('Drag and drop files here, or click to browse') .closest('div'); const existingFile = new File(['content'], 'existing-file.txt', { type: 'text/plain', @@ -271,11 +289,17 @@ describe('AddDocumentModal', () => { }); await waitFor(() => { - expect(defaultProps.onDuplicatesFound).toHaveBeenCalledWith([ - existingFile, - ]); expect(screen.getByText('new-file.txt')).toBeInTheDocument(); - expect(screen.queryByText('existing-file.txt')).not.toBeInTheDocument(); + expect(screen.getByText('existing-file.txt')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Add (2)' })); + + await waitFor(() => { + expect(defaultProps.onDuplicatesFound).toHaveBeenCalledWith( + [existingFile], + [existingFile, newFile], + ); }); }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/NotebookCard.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/NotebookCard.test.tsx index a2823cfcb77..b65562b2001 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/NotebookCard.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/NotebookCard.test.tsx @@ -75,9 +75,39 @@ describe('NotebookCard', () => { expect(screen.getByText('My Notebook')).toBeInTheDocument(); }); - it('should render the document count', () => { + it('should render the resource count for multiple resources', () => { render(); - expect(screen.getByText(/2/)).toBeInTheDocument(); + expect(screen.getByText('2 resources')).toBeInTheDocument(); + }); + + it('should render "No resources" when document_count is 0', () => { + render( + , + ); + expect(screen.getByText('No resources')).toBeInTheDocument(); + }); + + it('should render "1 resource" when document_count is 1', () => { + render( + , + ); + expect(screen.getByText('1 resource')).toBeInTheDocument(); + }); + + it('should render "No resources" when document_count is undefined', () => { + render( + , + ); + expect(screen.getByText('No resources')).toBeInTheDocument(); }); it('should call onClick with notebook when card is clicked', () => { @@ -225,6 +255,6 @@ describe('NotebookCard', () => { it('should render document_count from the notebook session', () => { render(); - expect(screen.getByText(/2/)).toBeInTheDocument(); + expect(screen.getByText('2 resources')).toBeInTheDocument(); }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/OverwriteConfirmModal.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/OverwriteConfirmModal.test.tsx index 076cdd46065..b6c0c491b3d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/OverwriteConfirmModal.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/OverwriteConfirmModal.test.tsx @@ -23,131 +23,195 @@ jest.mock('../../hooks/useTranslation', () => ({ useTranslation: jest.fn(() => mockUseTranslation()), })); +const createFile = (name: string) => + new File(['content'], name, { type: 'text/plain' }); + describe('OverwriteConfirmModal', () => { const onClose = jest.fn(); const onConfirm = jest.fn(); - const fileNames = ['report.pdf', 'data.yaml', 'notes.txt']; + const onBack = jest.fn(); + + const allFiles = [ + createFile('report.pdf'), + createFile('data.yaml'), + createFile('notes.txt'), + ]; + const duplicateFileNames = ['report.pdf', 'data.yaml']; beforeEach(() => { jest.clearAllMocks(); }); - it('should render the modal with file list when open', () => { + const renderModal = (props = {}) => render( , ); + it('should render the modal with all files when open', () => { + renderModal(); + 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( - , - ); + renderModal({ isOpen: false }); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('should render a warning alert', () => { - render( - , - ); + renderModal(); - expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/2 files already exist/)).toBeInTheDocument(); + }); + + it('should render radio options for replace and ignore', () => { + renderModal(); + + expect(screen.getByLabelText('Replace existing files')).toBeInTheDocument(); + expect( + screen.getByLabelText('Ignore duplicated files'), + ).toBeInTheDocument(); }); it('should render FileTypeIcon badges for each file', () => { - render( - , - ); + renderModal(); 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( - , - ); + it('should call onConfirm with all files when replace is selected', () => { + renderModal(); - const overwriteButton = screen.getByRole('button', { - name: 'Overwrite', + const uploadButton = screen.getByRole('button', { + name: /Upload \(3\)/, }); - fireEvent.click(overwriteButton); + fireEvent.click(uploadButton); - expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(allFiles); }); - it('should call onClose when cancel button is clicked', () => { - render( - , - ); + it('should call onConfirm with only new files when ignore is selected', () => { + renderModal(); - const cancelButton = screen.getByRole('button', { name: 'Cancel' }); - fireEvent.click(cancelButton); + const ignoreRadio = screen.getByLabelText('Ignore duplicated files'); + fireEvent.click(ignoreRadio); - expect(onClose).toHaveBeenCalledTimes(1); + const uploadButton = screen.getByRole('button', { + name: /Upload \(1\)/, + }); + fireEvent.click(uploadButton); + + expect(onConfirm).toHaveBeenCalledWith([allFiles[2]]); + }); + + it('should call onBack when Back button is clicked', () => { + renderModal(); + + const backButton = screen.getByRole('button', { name: 'Back' }); + fireEvent.click(backButton); + + expect(onBack).toHaveBeenCalledTimes(1); }); it('should call onClose when close icon button is clicked', () => { - render( + renderModal(); + + const closeButton = screen.getByRole('button', { name: 'Close' }); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should update upload count when switching from ignore back to replace', () => { + renderModal(); + + const ignoreRadio = screen.getByLabelText('Ignore duplicated files'); + fireEvent.click(ignoreRadio); + expect( + screen.getByRole('button', { name: /Upload \(1\)/ }), + ).toBeInTheDocument(); + + const replaceRadio = screen.getByLabelText('Replace existing files'); + fireEvent.click(replaceRadio); + expect( + screen.getByRole('button', { name: /Upload \(3\)/ }), + ).toBeInTheDocument(); + }); + + it('should render an empty file list gracefully when no files provided', () => { + renderModal({ allFiles: [], duplicateFileNames: [] }); + + expect(screen.queryByRole('listitem')).not.toBeInTheDocument(); + }); + + it('should disable Upload button when all files are duplicates and ignore is selected', () => { + renderModal({ + allFiles: [createFile('dup1.txt'), createFile('dup2.txt')], + duplicateFileNames: ['dup1.txt', 'dup2.txt'], + }); + + const ignoreRadio = screen.getByLabelText('Ignore duplicated files'); + fireEvent.click(ignoreRadio); + + const uploadButton = screen.getByRole('button', { + name: /Upload \(0\)/, + }); + expect(uploadButton).toBeDisabled(); + }); + + it('should reset radio to replace when modal reopens', () => { + const { rerender } = render( , ); - const closeButton = screen.getByRole('button', { name: 'Close' }); - fireEvent.click(closeButton); - - expect(onClose).toHaveBeenCalledTimes(1); - }); + fireEvent.click(screen.getByLabelText('Ignore duplicated files')); + expect( + screen.getByRole('button', { name: /Upload \(1\)/ }), + ).toBeInTheDocument(); - it('should render an empty list for no files', () => { - render( + rerender( + , + ); + rerender( , ); - expect(screen.queryByRole('listitem')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Upload \(3\)/ }), + ).toBeInTheDocument(); }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx index 7b85fbb5946..1a3958dc49a 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx @@ -14,12 +14,11 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; 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'; @@ -29,20 +28,31 @@ import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import { + Alert, MultipleFileUpload, + MultipleFileUploadContext, MultipleFileUploadMain, + Tooltip, } from '@patternfly/react-core'; import { UploadIcon } from '@patternfly/react-icons'; -import { NOTEBOOK_MAX_FILES } from '../../const'; +import { + NOTEBOOK_EXTENSION_TO_FILE_TYPE, + NOTEBOOK_MAX_FILES, +} from '../../const'; import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; import { useTranslation } from '../../hooks/useTranslation'; +import { runFileUploads } from '../../utils/notebook-upload-runner'; import { getNotebookAcceptedFileTypes, validateFiles, } from '../../utils/notebook-upload-utils'; import { FileListItem } from './FileListItem'; +const UNIQUE_FILE_TYPE_LABELS = [ + ...new Set(Object.values(NOTEBOOK_EXTENSION_TO_FILE_TYPE)), +].map(t => t.toUpperCase()); + const useStyles = makeStyles(theme => ({ dialogPaper: { borderRadius: 24, @@ -68,17 +78,30 @@ const useStyles = makeStyles(theme => ({ }, errorAlert: { marginBottom: theme.spacing(2), + '--pf-v6-c-alert--PaddingBlockEnd': '0', + '& .pf-v6-c-alert__title': { + marginBlockStart: 0, + }, }, dropzone: { - '& .pf-v6-c-multiple-file-upload__main': { - borderColor: 'var(--pf-t--global--border--color--brand--default)', - transition: 'background-color 0.2s ease', - cursor: 'pointer', - }, - '& .pf-v6-c-multiple-file-upload__main:hover': { + borderColor: 'var(--pf-t--global--border--color--brand--default)', + borderWidth: 2, + borderStyle: 'dashed', + borderRadius: theme.spacing(1), + padding: theme.spacing(2), + transition: 'background-color 0.2s ease', + cursor: 'pointer', + '&:hover': { backgroundColor: 'color-mix(in srgb, var(--pf-t--global--color--brand--default) 10%, transparent)', }, + '& .pf-v6-c-multiple-file-upload__main': { + border: 'none', + paddingBottom: 0, + }, + '& .pf-v6-c-multiple-file-upload__title-icon': { + fontSize: '2rem', + }, }, fileListContainer: { marginTop: theme.spacing(2), @@ -97,7 +120,7 @@ const useStyles = makeStyles(theme => ({ }, dialogActions: { padding: '16px 24px', - justifyContent: 'flex-end', + justifyContent: 'flex-start', gap: theme.spacing(1), }, addButton: { @@ -106,8 +129,83 @@ const useStyles = makeStyles(theme => ({ cancelButton: { textTransform: 'none', }, + uploadIcon: { + color: 'var(--pf-t--global--icon--color--brand--default)', + }, + supportedFormatsLabel: { + fontSize: '0.875rem', + color: theme.palette.text.secondary, + textAlign: 'center', + marginTop: theme.spacing(1), + }, + chipContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.75), + justifyContent: 'center', + marginTop: theme.spacing(0.5), + }, + fileTypeChip: { + display: 'inline-block', + padding: '2px 10px', + borderRadius: 12, + fontSize: '0.75rem', + fontWeight: 500, + backgroundColor: + theme.palette.type === 'dark' + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.08)', + color: theme.palette.text.secondary, + }, + maxFileSizeText: { + fontSize: '0.875rem', + color: theme.palette.text.secondary, + textAlign: 'center', + marginTop: theme.spacing(1), + }, + dropzoneDisabled: { + opacity: 0.5, + pointerEvents: 'none', + cursor: 'default', + '&:hover': { + backgroundColor: 'transparent', + }, + }, })); +const DropzoneClickArea = ({ + children, + isDisabled, + ariaLabel, +}: { + children: React.ReactNode; + isDisabled?: boolean; + ariaLabel?: string; +}) => { + const { open } = useContext(MultipleFileUploadContext); + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + open(); + } + } + } + style={{ cursor: isDisabled ? 'default' : 'pointer' }} + > + {children} +
+ ); +}; + type AddDocumentModalProps = { isOpen: boolean; onClose: () => void; @@ -117,7 +215,7 @@ type AddDocumentModalProps = { onFilesUploading?: (files: File[]) => void; onUploadStarted?: (info: { fileName: string; documentId: string }) => void; onUploadFailed?: (fileName: string) => void; - onDuplicatesFound?: (files: File[]) => void; + onDuplicatesFound?: (duplicateFiles: File[], allFiles: File[]) => void; filesToAdd?: File[]; onFilesAdded?: () => void; }; @@ -145,6 +243,13 @@ export const AddDocumentModal = ({ existingDocumentNames.length + selectedFiles.length; const remainingSlots = NOTEBOOK_MAX_FILES - totalExistingAndSelected; + useEffect(() => { + if (!isOpen) { + setSelectedFiles([]); + setValidationErrors([]); + } + }, [isOpen]); + useEffect(() => { if (filesToAdd && filesToAdd.length > 0) { setSelectedFiles(prev => [...prev, ...filesToAdd]); @@ -164,18 +269,8 @@ export const AddDocumentModal = ({ if (valid.length === 0) return; - const existingNamesSet = new Set([ - ...existingDocumentNames, - ...selectedFiles.map(f => f.name), - ]); - const newFiles = valid.filter(f => !existingNamesSet.has(f.name)); - const duplicateFiles = valid.filter(f => - existingDocumentNames.includes(f.name), - ); - - if (duplicateFiles.length > 0) { - onDuplicatesFound?.(duplicateFiles); - } + const alreadySelectedNames = new Set(selectedFiles.map(f => f.name)); + const newFiles = valid.filter(f => !alreadySelectedNames.has(f.name)); if (newFiles.length > 0) { setSelectedFiles(prev => [...prev, ...newFiles]); @@ -189,21 +284,21 @@ export const AddDocumentModal = ({ 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); - }); + const duplicateFiles = selectedFiles.filter(f => + existingDocumentNames.includes(f.name), + ); + + if (duplicateFiles.length > 0) { + onDuplicatesFound?.(duplicateFiles, selectedFiles); + return; } + runFileUploads(uploadMutation, sessionId, selectedFiles, { + onUploading: onFilesUploading, + onStarted: onUploadStarted, + onFailed: onUploadFailed, + }); + setSelectedFiles([]); setValidationErrors([]); onClose(); @@ -251,8 +346,11 @@ export const AddDocumentModal = ({ {validationErrors.length > 0 && ( - - {validationErrors + { const message = (t as Function)(errorKey) as string; return errorKey === 'notebook.upload.error.tooManyFiles' @@ -260,33 +358,67 @@ export const AddDocumentModal = ({ : message; }) .join('\n')} - + /> )} {hasUploadsInProgress && ( - - {t('notebook.view.documents.uploadsInProgress')} - + )} - {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')} - /> - - )} + {(() => { + const isDropzoneDisabled = remainingSlots <= 0; + const dropzoneContent = ( + + + } + titleText={t('notebook.upload.modal.dragDropTitle')} + isUploadButtonHidden + /> + + {t('notebook.upload.modal.supportedFormats')} + +
+ {UNIQUE_FILE_TYPE_LABELS.map(label => ( + + {label} + + ))} +
+ + {t('notebook.upload.modal.maxFileSize')} + +
+
+ ); + + return isDropzoneDisabled ? ( + +
{dropzoneContent}
+
+ ) : ( + dropzoneContent + ); + })()} {selectedFiles.length > 0 && ( @@ -316,13 +448,6 @@ export const AddDocumentModal = ({
- + diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx index b9b0ab6cbb5..93ea12da507 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx @@ -27,7 +27,7 @@ import { TextInput, Tooltip, } from '@patternfly/react-core'; -import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { AddCircleOIcon, EllipsisVIcon } from '@patternfly/react-icons'; import { NOTEBOOK_MAX_FILES } from '../../const'; import { useInlineEdit } from '../../hooks/notebooks/useInlineEdit'; @@ -252,7 +252,7 @@ export const DocumentSidebar = ({ ); }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookCard.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookCard.tsx index 90d2cb2ea34..9e31298eaa7 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookCard.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookCard.tsx @@ -30,8 +30,12 @@ import { MenuToggle, TextInput, } from '@patternfly/react-core'; -import { EllipsisVIcon } from '@patternfly/react-icons'; -import { CatalogIcon } from '@patternfly/react-icons/dist/esm/icons'; +import { + CatalogIcon, + EllipsisVIcon, + PenIcon, + TrashIcon, +} from '@patternfly/react-icons'; import { useInlineEdit } from '../../hooks/notebooks/useInlineEdit'; import { intelligentAssistantTranslationRef } from '../../translations/ref'; @@ -103,12 +107,22 @@ export const NotebookCard = ({ } }; + const count = notebook.document_count ?? 0; + const getDocumentCountText = () => { + if (count === 0) return t('notebooks.documents.none'); + if (count === 1) return t('notebooks.documents.one'); + return (t as Function)('notebooks.documents.other', { count }); + }; + const documentCountText = getDocumentCountText(); + return ( @@ -149,6 +163,7 @@ export const NotebookCard = ({ } onClick={event => { event.stopPropagation(); startEditing(); @@ -158,6 +173,7 @@ export const NotebookCard = ({ } onClick={event => { event.stopPropagation(); onDelete(notebook.session_id); @@ -204,9 +220,7 @@ export const NotebookCard = ({
- - {notebook.document_count ?? 0} {t('notebooks.documents')} - + {documentCountText}
diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index afd236379c5..232ba80eb18 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -38,7 +38,7 @@ import { Tooltip, type AlertProps, } from '@patternfly/react-core'; -import { TimesIcon } from '@patternfly/react-icons'; +import { PlusIcon, TimesIcon } from '@patternfly/react-icons'; import { useQueryClient } from '@tanstack/react-query'; import { notebooksApiRef } from '../../api/notebooksApi'; @@ -53,12 +53,15 @@ import { type PendingUpload, } from '../../hooks/notebooks/useDocumentStatusPolling'; import { useRenameNotebookWithAlert } from '../../hooks/notebooks/useRenameNotebookWithAlert'; +import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; import { useConversationMessages } from '../../hooks/useConversationMessages'; import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage'; import { useNotebookWelcomePrompts } from '../../hooks/useNotebookWelcomePrompts'; +import { useStopConversation } from '../../hooks/useStopConversation'; import { useTranslation } from '../../hooks/useTranslation'; import { NotebookSessionMetadata, SessionDocument } from '../../types'; import { ChatbotFootnoteWithIcon } from '../../utils/lightspeed-chatbox-utils'; +import { runFileUploads } from '../../utils/notebook-upload-runner'; import { LightspeedChatBox } from '../LightspeedChatBox'; import { AddDocumentModal } from './AddDocumentModal'; import { DeleteDocumentModal } from './DeleteDocumentModal'; @@ -257,11 +260,34 @@ const useStyles = makeStyles(theme => ({ ? theme.palette.grey[100] : 'var(--pf-t--global--background--color--secondary--default)', }, - '& .pf-chatbot__button--send, & .pf-chatbot__button--microphone': { - '--pf-v6-c-button--BorderRadius': - 'var(--pf-t--global--border--radius--pill)', - borderRadius: 'var(--pf-t--global--border--radius--pill) !important', + '& .pf-chatbot__button--stop, & .pf-chatbot__button--attach, & .pf-chatbot__button--send, & .pf-chatbot__button--microphone': + { + '--pf-v6-c-button--BorderRadius': + 'var(--pf-t--global--border--radius--pill)', + borderRadius: 'var(--pf-t--global--border--radius--pill) !important', + }, + }, + messageBar: { + border: '1px solid var(--pf-t--global--border--color--default)', + borderRadius: 24, + padding: theme.spacing(0.5), + '&::after': { + display: 'none', + }, + }, + addResourceButton: { + background: 'none', + border: 'none', + cursor: 'pointer', + padding: 4, + display: 'inline-flex', + alignItems: 'center', + color: 'inherit', + '&:focus-visible': { + outline: '2px solid var(--pf-t--global--border--color--brand--default)', + outlineOffset: 2, }, + marginLeft: theme.spacing(2), }, chatContent: { minHeight: 0, @@ -316,6 +342,9 @@ export const NotebookView = ({ metadata?.conversation_id ?? TEMP_CONVERSATION_ID, ); const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const [requestId, setRequestId] = useState(''); + const { mutate: stopConversation } = useStopConversation(); + const wasStoppedByUserRef = useRef(false); const [announcement, setAnnouncement] = useState( undefined, ); @@ -334,7 +363,10 @@ export const NotebookView = ({ const onComplete = useCallback( (message: string) => { setIsSendButtonDisabled(false); - setAnnouncement(`Message from Bot: ${message}`); + if (!wasStoppedByUserRef.current) { + setAnnouncement(`Message from Bot: ${message}`); + } + wasStoppedByUserRef.current = false; queryClient.invalidateQueries({ queryKey: ['conversationMessages', conversationId], }); @@ -356,6 +388,10 @@ export const NotebookView = ({ [notebookCreateMessage, sessionId], ); + const onRequestIdReady = useCallback((rid: string) => { + setRequestId(rid); + }, []); + const { conversationMessages, handleInputPrompt, scrollToBottomRef } = useConversationMessages( conversationId, @@ -366,6 +402,7 @@ export const NotebookView = ({ onComplete, onStart, createMessageAdapter, + onRequestIdReady, ); const [messages, setMessages] = @@ -377,6 +414,7 @@ export const NotebookView = ({ const sendMessage = useCallback( (message: string | number) => { + wasStoppedByUserRef.current = false; setAnnouncement( t('conversation.announcement.userMessage' as any, { prompt: message.toString(), @@ -388,12 +426,24 @@ export const NotebookView = ({ [handleInputPrompt, t], ); + const handleStopButton = useCallback(() => { + wasStoppedByUserRef.current = true; + if (requestId) { + stopConversation(requestId); + setRequestId(''); + } + setIsSendButtonDisabled(false); + setAnnouncement(t('conversation.announcement.responseStopped')); + }, [requestId, stopConversation, t]); + const notebookPrompts = useNotebookWelcomePrompts(); const welcomePrompts = notebookPrompts.map(title => ({ title, onClick: () => sendMessage(title), })); + const uploadMutation = useUploadDocument(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); const [uploadingFileNames, setUploadingFileNames] = useState([]); @@ -404,6 +454,7 @@ export const NotebookView = ({ new Set(), ); const [filesToOverwrite, setFilesToOverwrite] = useState([]); + const [allFilesForOverwrite, setAllFilesForOverwrite] = useState([]); const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); const [filesToAddToModal, setFilesToAddToModal] = useState([]); @@ -444,6 +495,26 @@ export const NotebookView = ({ const handleOpenUploadModal = () => setIsUploadModalOpen(true); const handleCloseUploadModal = () => setIsUploadModalOpen(false); + const handleCloseNotebook = async () => { + const isUntitled = notebookName === UNTITLED_NOTEBOOK_NAME; + const hasNoDocuments = + documents.length === 0 && completedFileNames.size === 0; + const hasNoPendingUploads = !pendingUploads.length; + const hasNoUploading = !uploadingFileNames.length; + const hasNoChat = conversationId === TEMP_CONVERSATION_ID; + + if ( + isUntitled && + hasNoDocuments && + hasNoPendingUploads && + hasNoUploading && + hasNoChat + ) { + await notebooksApi.deleteSession(sessionId).catch(() => {}); + } + onClose(); + }; + const handleFilesUploading = (files: File[]) => { setUploadingFileNames(prev => { const newNames = files @@ -478,28 +549,43 @@ export const NotebookView = ({ ]); }; - const handleDuplicatesFound = (files: File[]) => { - setFilesToOverwrite(files); + const handleDuplicatesFound = (duplicateFiles: File[], allFiles: File[]) => { + setFilesToOverwrite(duplicateFiles); + setAllFilesForOverwrite(allFiles); + setIsUploadModalOpen(false); setIsOverwriteModalOpen(true); }; - const handleOverwriteConfirm = () => { - const files = filesToOverwrite; + const handleOverwriteConfirm = (filesToUpload: File[]) => { setIsOverwriteModalOpen(false); setFilesToOverwrite([]); + setAllFilesForOverwrite([]); - if (files.length === 0) return; + if (filesToUpload.length === 0) return; - setFilesToAddToModal(files); + runFileUploads(uploadMutation, sessionId, filesToUpload, { + onUploading: handleFilesUploading, + onStarted: handleUploadStarted, + onFailed: handleUploadFailed, + }); }; const handleFilesAddedToModal = () => { setFilesToAddToModal([]); }; + const handleOverwriteBack = () => { + setIsOverwriteModalOpen(false); + setFilesToAddToModal(allFilesForOverwrite); + setFilesToOverwrite([]); + setAllFilesForOverwrite([]); + setIsUploadModalOpen(true); + }; + const handleOverwriteCancel = () => { setIsOverwriteModalOpen(false); setFilesToOverwrite([]); + setAllFilesForOverwrite([]); }; const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); @@ -747,7 +833,7 @@ export const NotebookView = ({ + ); + return hasNoDocuments ? ( + +
+ +
+
+ ) : ( + + ); + })()}
@@ -815,7 +938,9 @@ export const NotebookView = ({ isOpen={isOverwriteModalOpen} onClose={handleOverwriteCancel} onConfirm={handleOverwriteConfirm} - fileNames={filesToOverwrite.map(f => f.name)} + onBack={handleOverwriteBack} + allFiles={allFilesForOverwrite} + duplicateFileNames={filesToOverwrite.map(f => f.name)} /> ({ dialogContent: { padding: '0 24px 24px', }, + warningAlert: { + '--pf-v6-c-alert--PaddingBlockEnd': '0', + marginBottom: theme.spacing(2), + '& .pf-v6-c-alert__title': { + marginTop: 0, + }, + }, + radioGroup: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + marginBottom: theme.spacing(2), + '& label': { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + cursor: 'pointer', + fontSize: '0.875rem', + }, + '& input[type="radio"]': { + cursor: 'pointer', + }, + }, fileList: { margin: 0, padding: 0, listStyle: 'none', + maxHeight: 300, + overflowY: 'auto', }, 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)', - cursor: 'pointer', + padding: `${theme.spacing(1.5)}px ${theme.spacing(1.5)}px`, + border: '1px solid var(--pf-t--global--border--color--default)', + borderRadius: 8, + marginBottom: theme.spacing(1), }, fileName: { flex: 1, @@ -74,44 +100,69 @@ const useStyles = makeStyles(theme => ({ fontSize: '0.875rem', lineHeight: '1.25rem', }, + warningIcon: { + color: 'var(--pf-t--global--color--status--warning--default)', + fontSize: '1rem', + flexShrink: 0, + }, dialogActions: { - justifyContent: 'left', - padding: theme.spacing(2.5), + display: 'flex', + justifyContent: 'flex-start', + padding: '16px 24px', 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[]; + onConfirm: (filesToUpload: File[]) => void; + onBack: () => void; + allFiles: File[]; + duplicateFileNames: string[]; }; export const OverwriteConfirmModal = ({ isOpen, onClose, onConfirm, - fileNames, + onBack, + allFiles, + duplicateFileNames, }: OverwriteConfirmModalProps) => { const classes = useStyles(); const { t } = useTranslation(); + const [duplicateAction, setDuplicateAction] = useState<'replace' | 'ignore'>( + 'replace', + ); + + useEffect(() => { + if (isOpen) setDuplicateAction('replace'); + }, [isOpen]); + + const duplicateSet = useMemo( + () => new Set(duplicateFileNames), + [duplicateFileNames], + ); + const newFiles = allFiles.filter(f => !duplicateSet.has(f.name)); + const duplicateFiles = allFiles.filter(f => duplicateSet.has(f.name)); + + const filesToUpload = duplicateAction === 'replace' ? allFiles : newFiles; + + const handleConfirm = () => { + onConfirm(filesToUpload); + setDuplicateAction('replace'); + }; + + const handleClose = () => { + setDuplicateAction('replace'); + onClose(); + }; return ( - {t('notebook.overwrite.modal.title')} + {(t as Function)( + duplicateFiles.length === 1 + ? 'notebook.overwrite.modal.title.one' + : 'notebook.overwrite.modal.title.other', + )} @@ -132,37 +187,69 @@ export const OverwriteConfirmModal = ({ - - {t('notebook.overwrite.modal.description')} - + + +
+ + +
    - {fileNames.map(name => ( -
  • - - {name} + {allFiles.map(file => ( +
  • + + {file.name} + {duplicateSet.has(file.name) && ( + + )}
  • ))}
- +
- - +
); }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx index 2653946517a..c3dca669e0a 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx @@ -97,7 +97,6 @@ export const UploadResourceScreen = ({ variant="secondary" className={classes.uploadButton} icon={} - iconPosition="end" onClick={onUploadClick} > {t('notebook.view.upload.action')} diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts index 37fbec9b75e..3c72a8a837c 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/de.ts @@ -209,10 +209,19 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'notebook.document.delete.success': '„{{documentName}}" wurde erfolgreich entfernt.', 'notebook.document.delete.title': 'Ressource entfernen?', - 'notebook.overwrite.modal.action': 'Überschreiben', + 'notebook.overwrite.modal.action': 'Hochladen ({{count}})', + 'notebook.overwrite.modal.back': 'Zurück', 'notebook.overwrite.modal.description': - 'Die folgenden Dateien existieren bereits in diesem Notizbuch. Möchten Sie sie mit den neuen Versionen überschreiben?', - 'notebook.overwrite.modal.title': 'Dateien überschreiben?', + '{{duplicateCount}} Dateien existieren bereits in diesem Notizbuch. {{newCount}} neue Ressourcen werden trotzdem hinzugefügt.', + 'notebook.overwrite.modal.ignore': 'Doppelte Dateien ignorieren', + 'notebook.overwrite.modal.replace': 'Bestehende Dateien ersetzen', + 'notebook.overwrite.modal.title': 'Datei existiert bereits', + 'notebook.overwrite.modal.title.one': 'Datei existiert bereits', + 'notebook.overwrite.modal.title.other': 'Dateien existieren bereits', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} Datei existiert bereits in diesem Notizbuch. {{newCount}} neue Ressourcen werden trotzdem hinzugefügt.', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} Dateien existieren bereits in diesem Notizbuch. {{newCount}} neue Ressourcen werden trotzdem hinzugefügt.', 'notebook.upload.error.fileTooLarge': 'Upload-Fehler: Dateigröße überschreitet das Limit von 25 MB.', 'notebook.upload.error.tooManyFiles': @@ -221,15 +230,19 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'Upload-Fehler: Nicht unterstützte Dateitypen gefunden. Bitte laden Sie nur unterstützte Dateitypen hoch.', 'notebook.upload.failed': 'Hochladen von "{{fileName}}" fehlgeschlagen.', 'notebook.upload.modal.addButton': 'Hinzufügen ({{count}})', + 'notebook.upload.modal.addButtonEmpty': 'Hinzufügen', 'notebook.upload.modal.browseButton': 'Hochladen', - 'notebook.upload.modal.dragDropTitle': 'Dateien hierher ziehen und ablegen', + 'notebook.upload.modal.dragDropTitle': + 'Dateien hierher ziehen und ablegen oder zum Durchsuchen klicken', 'notebook.upload.modal.infoText': 'Akzeptierte Dateitypen: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.maxFileSize': 'Maximale Dateigröße beträgt 25 MB.', + 'notebook.upload.modal.supportedFormats': 'Unterstützte Formate:', 'notebook.upload.modal.removeFile': '{{fileName}} entfernen', 'notebook.upload.modal.selectedFiles': '{{count}} von {{max}} Dateien ausgewählt', 'notebook.upload.modal.separator': 'oder', - 'notebook.upload.modal.title': 'Ressource zum Notizbuch hinzufügen', + 'notebook.upload.modal.title': 'Ressourcen hinzufügen', 'notebook.view.close': 'Notizbuch schließen', 'notebook.view.documents.add': 'Hinzufügen', 'notebook.view.documents.count': '{{count}} Ressourcen', @@ -243,12 +256,12 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'notebook.view.sidebar.expand': 'Seitenleiste ausklappen', 'notebook.view.sidebar.resize': 'Größe der Seitenleiste ändern', 'notebook.view.title': 'Unbenanntes Notizbuch', - 'notebook.view.upload.action': 'Ressource hochladen', + 'notebook.view.upload.action': 'Ressource hinzufügen', 'notebook.view.processing.description': 'Ihre Dateien werden indexiert. Sie können Fragen stellen, sobald die Verarbeitung abgeschlossen ist.', 'notebook.view.processing.heading': 'Ressourcen werden verarbeitet...', 'notebook.view.upload.heading': - 'Laden Sie eine Ressource hoch, um zu beginnen', + 'Fügen Sie eine Ressource hinzu, um zu beginnen', 'notebooks.actions.delete': 'Löschen', 'notebooks.actions.rename': 'Umbenennen', 'notebooks.card.openAria': 'Notizbuch {{name}} öffnen', @@ -258,6 +271,9 @@ const intelligentAssistantTranslationDe = createTranslationMessages({ 'notebooks.delete.title': '{{name}} löschen?', 'notebooks.delete.toast': 'Notizbuch gelöscht!', 'notebooks.documents': 'Ressourcen', + 'notebooks.documents.none': 'Keine Ressourcen', + 'notebooks.documents.one': '1 Ressource', + 'notebooks.documents.other': '{{count}} Ressourcen', 'notebooks.empty.action': 'Neues Notizbuch erstellen', 'notebooks.empty.description': 'Erstellen Sie ein neues Notizbuch, um Ihre Quellen zu organisieren und KI-gestützte Erkenntnisse zu gewinnen.', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts index 61c78bffdb7..112f6dbaec1 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/es.ts @@ -205,10 +205,19 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'notebook.document.delete.success': '«{{documentName}}» se eliminó correctamente.', 'notebook.document.delete.title': '¿Eliminar recurso?', - 'notebook.overwrite.modal.action': 'Sobrescribir', + 'notebook.overwrite.modal.action': 'Subir ({{count}})', + 'notebook.overwrite.modal.back': 'Atrás', 'notebook.overwrite.modal.description': - 'Los siguientes archivos ya existen en este cuaderno. ¿Desea sobrescribirlos con las nuevas versiones?', - 'notebook.overwrite.modal.title': '¿Sobrescribir archivos?', + '{{duplicateCount}} archivos ya existen en este cuaderno. {{newCount}} nuevos recursos se agregarán de todos modos.', + 'notebook.overwrite.modal.ignore': 'Ignorar archivos duplicados', + 'notebook.overwrite.modal.replace': 'Reemplazar archivos existentes', + 'notebook.overwrite.modal.title': 'El archivo ya existe', + 'notebook.overwrite.modal.title.one': 'El archivo ya existe', + 'notebook.overwrite.modal.title.other': 'Los archivos ya existen', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} archivo ya existe en este cuaderno. {{newCount}} nuevos recursos se agregarán de todos modos.', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} archivos ya existen en este cuaderno. {{newCount}} nuevos recursos se agregarán de todos modos.', 'notebook.upload.error.fileTooLarge': 'Error de carga: el tamaño del archivo supera el límite de 25 MB.', 'notebook.upload.error.tooManyFiles': @@ -217,16 +226,20 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'Error de carga: se encontraron tipos de archivo no compatibles. Suba solo tipos de archivo compatibles.', 'notebook.upload.failed': 'Error al subir "{{fileName}}".', 'notebook.upload.modal.addButton': 'Agregar ({{count}})', + 'notebook.upload.modal.addButtonEmpty': 'Agregar', 'notebook.upload.modal.browseButton': 'Subir', 'notebook.upload.modal.dragDropTitle': - 'Arrastra y suelta los archivos aquí', + 'Arrastra y suelta los archivos aquí, o haz clic para explorar', 'notebook.upload.modal.infoText': 'Tipos de archivo aceptados: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.maxFileSize': + 'El tamaño máximo del archivo es 25 MB.', + 'notebook.upload.modal.supportedFormats': 'Formatos compatibles:', 'notebook.upload.modal.removeFile': 'Eliminar {{fileName}}', 'notebook.upload.modal.selectedFiles': '{{count}} de {{max}} archivos seleccionados', 'notebook.upload.modal.separator': 'o', - 'notebook.upload.modal.title': 'Agregar un recurso al cuaderno', + 'notebook.upload.modal.title': 'Agregar recursos', 'notebook.view.close': 'Cerrar cuaderno', 'notebook.view.documents.add': 'Agregar', 'notebook.view.documents.count': '{{count}} Recursos', @@ -240,11 +253,11 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'notebook.view.sidebar.expand': 'Expandir barra lateral', 'notebook.view.sidebar.resize': 'Redimensionar barra lateral', 'notebook.view.title': 'Cuaderno sin título', - 'notebook.view.upload.action': 'Subir un recurso', + 'notebook.view.upload.action': 'Agregar un recurso', 'notebook.view.processing.description': 'Sus archivos están siendo indexados. Puede comenzar a hacer preguntas una vez que se complete el procesamiento.', 'notebook.view.processing.heading': 'Procesando recursos...', - 'notebook.view.upload.heading': 'Sube un recurso para empezar', + 'notebook.view.upload.heading': 'Agrega un recurso para empezar', 'notebooks.actions.delete': 'Eliminar', 'notebooks.actions.rename': 'Renombrar', 'notebooks.card.openAria': 'Abrir el cuaderno {{name}}', @@ -254,6 +267,9 @@ const intelligentAssistantTranslationEs = createTranslationMessages({ 'notebooks.delete.title': '¿Eliminar {{name}}?', 'notebooks.delete.toast': '¡Cuaderno eliminado!', 'notebooks.documents': 'Recursos', + 'notebooks.documents.none': 'Sin recursos', + 'notebooks.documents.one': '1 recurso', + 'notebooks.documents.other': '{{count}} recursos', 'notebooks.empty.action': 'Crear un cuaderno nuevo', 'notebooks.empty.description': 'Crea un nuevo cuaderno para organizar tus fuentes y generar información con IA.', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts index 23599d21420..97f1e837c6f 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/fr.ts @@ -208,10 +208,19 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'notebook.document.delete.success': '« {{documentName}} » supprimé avec succès.', 'notebook.document.delete.title': 'Supprimer la ressource ?', - 'notebook.overwrite.modal.action': 'Écraser', + 'notebook.overwrite.modal.action': 'Charger ({{count}})', + 'notebook.overwrite.modal.back': 'Retour', 'notebook.overwrite.modal.description': - 'Les fichiers suivants existent déjà dans ce carnet. Voulez-vous les écraser avec les nouvelles versions ?', - 'notebook.overwrite.modal.title': 'Écraser les fichiers ?', + '{{duplicateCount}} fichiers existent déjà dans ce carnet. {{newCount}} nouvelles ressources seront ajoutées quand même.', + 'notebook.overwrite.modal.ignore': 'Ignorer les fichiers en double', + 'notebook.overwrite.modal.replace': 'Remplacer les fichiers existants', + 'notebook.overwrite.modal.title': 'Le fichier existe déjà', + 'notebook.overwrite.modal.title.one': 'Le fichier existe déjà', + 'notebook.overwrite.modal.title.other': 'Les fichiers existent déjà', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} fichier existe déjà dans ce carnet. {{newCount}} nouvelles ressources seront ajoutées quand même.', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} fichiers existent déjà dans ce carnet. {{newCount}} nouvelles ressources seront ajoutées quand même.', 'notebook.upload.error.fileTooLarge': 'Erreur de chargement : la taille du fichier dépasse la limite de 25 Mo.', 'notebook.upload.error.tooManyFiles': @@ -220,15 +229,20 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'Erreur de chargement : type(s) de fichier non pris en charge. Veuillez charger uniquement des types de fichiers pris en charge.', 'notebook.upload.failed': 'Échec du chargement de "{{fileName}}".', 'notebook.upload.modal.addButton': 'Ajouter ({{count}})', + 'notebook.upload.modal.addButtonEmpty': 'Ajouter', 'notebook.upload.modal.browseButton': 'Charger', - 'notebook.upload.modal.dragDropTitle': 'Glissez-déposez les fichiers ici', + 'notebook.upload.modal.dragDropTitle': + 'Glissez-déposez les fichiers ici ou cliquez pour parcourir', 'notebook.upload.modal.infoText': 'Types de fichiers acceptés : .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.maxFileSize': + 'La taille maximale du fichier est de 25 Mo.', + 'notebook.upload.modal.supportedFormats': 'Formats pris en charge :', 'notebook.upload.modal.removeFile': 'Supprimer {{fileName}}', 'notebook.upload.modal.selectedFiles': '{{count}} sur {{max}} fichiers sélectionnés', 'notebook.upload.modal.separator': 'ou', - 'notebook.upload.modal.title': 'Ajouter une ressource au carnet', + 'notebook.upload.modal.title': 'Ajouter des ressources', 'notebook.view.close': 'Fermer le carnet', 'notebook.view.documents.add': 'Ajouter', 'notebook.view.documents.count': '{{count}} Ressources', @@ -243,11 +257,11 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'notebook.view.sidebar.expand': 'Développer la barre latérale', 'notebook.view.sidebar.resize': 'Redimensionner la barre latérale', 'notebook.view.title': 'Carnet sans titre', - 'notebook.view.upload.action': 'Charger une ressource', + 'notebook.view.upload.action': 'Ajouter une ressource', 'notebook.view.processing.description': 'Vos fichiers sont en cours d’indexation. Vous pourrez poser des questions une fois le traitement terminé.', 'notebook.view.processing.heading': 'Traitement des ressources...', - 'notebook.view.upload.heading': 'Chargez une ressource pour commencer', + 'notebook.view.upload.heading': 'Ajoutez une ressource pour commencer', 'notebooks.actions.delete': 'Supprimer', 'notebooks.actions.rename': 'Renommer', 'notebooks.card.openAria': 'Ouvrir le carnet {{name}}', @@ -257,6 +271,9 @@ const intelligentAssistantTranslationFr = createTranslationMessages({ 'notebooks.delete.title': 'Supprimer {{name}} ?', 'notebooks.delete.toast': 'Carnet supprimé !', 'notebooks.documents': 'Ressources', + 'notebooks.documents.none': 'Aucune ressource', + 'notebooks.documents.one': '1 ressource', + 'notebooks.documents.other': '{{count}} ressources', 'notebooks.empty.action': 'Créer un nouveau carnet', 'notebooks.empty.description': 'Créez un nouveau carnet pour organiser vos sources et générer des informations alimentées par l’IA.', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts index 1ab1f5e4196..5b7bb31bc73 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/it.ts @@ -206,10 +206,19 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'notebook.document.delete.success': '«{{documentName}}» rimosso con successo.', 'notebook.document.delete.title': 'Rimuovere la risorsa?', - 'notebook.overwrite.modal.action': 'Sovrascrivi', + 'notebook.overwrite.modal.action': 'Carica ({{count}})', + 'notebook.overwrite.modal.back': 'Indietro', 'notebook.overwrite.modal.description': - 'I seguenti file esistono già in questo quaderno. Vuoi sovrascriverli con le nuove versioni?', - 'notebook.overwrite.modal.title': 'Sovrascrivere i file?', + '{{duplicateCount}} file esistono già in questo quaderno. {{newCount}} nuove risorse verranno aggiunte comunque.', + 'notebook.overwrite.modal.ignore': 'Ignora file duplicati', + 'notebook.overwrite.modal.replace': 'Sostituisci file esistenti', + 'notebook.overwrite.modal.title': 'Il file esiste già', + 'notebook.overwrite.modal.title.one': 'Il file esiste già', + 'notebook.overwrite.modal.title.other': 'I file esistono già', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} file esiste già in questo quaderno. {{newCount}} nuove risorse verranno aggiunte comunque.', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} file esistono già in questo quaderno. {{newCount}} nuove risorse verranno aggiunte comunque.', 'notebook.upload.error.fileTooLarge': 'Errore di caricamento: la dimensione del file supera il limite di 25 MB.', 'notebook.upload.error.tooManyFiles': @@ -218,15 +227,20 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'Errore di caricamento: trovati tipi di file non supportati. Caricare solo tipi di file supportati.', 'notebook.upload.failed': 'Caricamento di "{{fileName}}" non riuscito.', 'notebook.upload.modal.addButton': 'Aggiungi ({{count}})', + 'notebook.upload.modal.addButtonEmpty': 'Aggiungi', 'notebook.upload.modal.browseButton': 'Carica', - 'notebook.upload.modal.dragDropTitle': 'Trascina e rilascia i file qui', + 'notebook.upload.modal.dragDropTitle': + 'Trascina e rilascia i file qui o fai clic per sfogliare', 'notebook.upload.modal.infoText': 'Tipi di file accettati: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.maxFileSize': + 'La dimensione massima del file è 25 MB.', + 'notebook.upload.modal.supportedFormats': 'Formati supportati:', 'notebook.upload.modal.removeFile': 'Rimuovi {{fileName}}', 'notebook.upload.modal.selectedFiles': '{{count}} di {{max}} file selezionati', 'notebook.upload.modal.separator': 'o', - 'notebook.upload.modal.title': 'Aggiungi una risorsa al quaderno', + 'notebook.upload.modal.title': 'Aggiungi risorse', 'notebook.view.close': 'Chiudi quaderno', 'notebook.view.documents.add': 'Aggiungi', 'notebook.view.documents.count': '{{count}} Risorse', @@ -241,11 +255,11 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'notebook.view.sidebar.expand': 'Espandi barra laterale', 'notebook.view.sidebar.resize': 'Ridimensiona barra laterale', 'notebook.view.title': 'Quaderno senza titolo', - 'notebook.view.upload.action': 'Carica una risorsa', + 'notebook.view.upload.action': 'Aggiungi una risorsa', 'notebook.view.processing.description': 'I tuoi file sono in fase di indicizzazione. Potrai iniziare a fare domande una volta completata l’elaborazione.', 'notebook.view.processing.heading': 'Elaborazione delle risorse...', - 'notebook.view.upload.heading': 'Carica una risorsa per iniziare', + 'notebook.view.upload.heading': 'Aggiungi una risorsa per iniziare', 'notebooks.actions.delete': 'Elimina', 'notebooks.actions.rename': 'Rinomina', 'notebooks.card.openAria': 'Apri il taccuino {{name}}', @@ -255,6 +269,9 @@ const intelligentAssistantTranslationIt = createTranslationMessages({ 'notebooks.delete.title': 'Eliminare {{name}}?', 'notebooks.delete.toast': 'Quaderno eliminato!', 'notebooks.documents': 'Risorse', + 'notebooks.documents.none': 'Nessuna risorsa', + 'notebooks.documents.one': '1 risorsa', + 'notebooks.documents.other': '{{count}} risorse', 'notebooks.empty.action': 'Crea un nuovo quaderno', 'notebooks.empty.description': 'Crea un nuovo quaderno per organizzare le tue fonti e generare insight basati su IA.', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts index d86e572a830..15c16c9198f 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ja.ts @@ -202,10 +202,19 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'notebook.document.delete.success': '「{{documentName}}」が正常に削除されました。', 'notebook.document.delete.title': 'リソースを削除しますか?', - 'notebook.overwrite.modal.action': '上書き', + 'notebook.overwrite.modal.action': 'アップロード ({{count}})', + 'notebook.overwrite.modal.back': '戻る', 'notebook.overwrite.modal.description': - '以下のファイルはこのノートブックに既に存在します。新しいバージョンで上書きしますか?', - 'notebook.overwrite.modal.title': 'ファイルを上書きしますか?', + '{{duplicateCount}} 件のファイルがこのノートブックに既に存在します。{{newCount}} 件の新しいリソースはそのまま追加されます。', + 'notebook.overwrite.modal.ignore': '重複ファイルを無視', + 'notebook.overwrite.modal.replace': '既存のファイルを置換', + 'notebook.overwrite.modal.title': 'ファイルが既に存在します', + 'notebook.overwrite.modal.title.one': 'ファイルが既に存在します', + 'notebook.overwrite.modal.title.other': 'ファイルが既に存在します', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} 件のファイルがこのノートブックに既に存在します。{{newCount}} 件の新しいリソースはそのまま追加されます。', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} 件のファイルがこのノートブックに既に存在します。{{newCount}} 件の新しいリソースはそのまま追加されます。', 'notebook.upload.error.fileTooLarge': 'アップロードエラー: ファイルサイズが 25 MB の制限を超えています。', 'notebook.upload.error.tooManyFiles': @@ -214,15 +223,19 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'アップロードエラー: サポートされていないファイル形式が見つかりました。サポートされているファイル形式のみをアップロードしてください。', 'notebook.upload.failed': '「{{fileName}}」のアップロードに失敗しました。', 'notebook.upload.modal.addButton': '追加 ({{count}})', + 'notebook.upload.modal.addButtonEmpty': '追加', 'notebook.upload.modal.browseButton': 'アップロード', - 'notebook.upload.modal.dragDropTitle': 'ここにファイルをドラッグ&ドロップ', + 'notebook.upload.modal.dragDropTitle': + 'ここにファイルをドラッグ&ドロップ、またはクリックして参照', 'notebook.upload.modal.infoText': '対応ファイル形式: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.maxFileSize': '最大ファイルサイズは 25 MB です。', + 'notebook.upload.modal.supportedFormats': 'サポートされている形式:', 'notebook.upload.modal.removeFile': '{{fileName}} を削除', 'notebook.upload.modal.selectedFiles': '{{max}} 件中 {{count}} 件のファイルを選択', 'notebook.upload.modal.separator': 'または', - 'notebook.upload.modal.title': 'ノートブックにリソースを追加', + 'notebook.upload.modal.title': 'リソースを追加', 'notebook.view.close': 'ノートブックを閉じる', 'notebook.view.documents.add': '追加', 'notebook.view.documents.count': '{{count}} 件のリソース', @@ -236,12 +249,11 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'notebook.view.sidebar.expand': 'サイドバーを展開する', 'notebook.view.sidebar.resize': 'サイドバーのサイズを変更する', 'notebook.view.title': '無題のノートブック', - 'notebook.view.upload.action': 'リソースをアップロード', + 'notebook.view.upload.action': 'リソースを追加', 'notebook.view.processing.description': 'ファイルをインデックスしています。処理が完了すると質問を開始できます。', 'notebook.view.processing.heading': 'リソースを処理中...', - 'notebook.view.upload.heading': - 'リソースをアップロードして開始してください', + 'notebook.view.upload.heading': 'リソースを追加して開始してください', 'notebooks.actions.delete': '削除', 'notebooks.actions.rename': '名前の変更', 'notebooks.card.openAria': 'ノートブック {{name}} を開く', @@ -251,6 +263,9 @@ const intelligentAssistantTranslationJa = createTranslationMessages({ 'notebooks.delete.title': '{{name}} を削除しますか?', 'notebooks.delete.toast': 'ノートブックを削除しました!', 'notebooks.documents': 'リソース', + 'notebooks.documents.none': 'リソースなし', + 'notebooks.documents.one': '1 件のリソース', + 'notebooks.documents.other': '{{count}} 件のリソース', 'notebooks.empty.action': '新しいノートブックを作成', 'notebooks.empty.description': '新しいノートブックを作成してソースを整理し、AI による洞察を生成します。', diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts index aefd87cd1a4..af46fcf13ea 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/translations/ref.ts @@ -36,6 +36,9 @@ export const intelligentAssistantMessages = { 'Start a new notebook to organize your sources and generate AI-powered insights.', 'notebooks.empty.action': 'Create a new notebook', 'notebooks.documents': 'Resources', + 'notebooks.documents.none': 'No resources', + 'notebooks.documents.one': '1 resource', + 'notebooks.documents.other': '{{count}} resources', 'notebooks.actions.rename': 'Rename', 'notebooks.actions.delete': 'Delete', 'notebooks.rename.inline.tooltip': 'Click to rename', @@ -61,8 +64,8 @@ export const intelligentAssistantMessages = { 'notebook.view.close': 'Close notebook', 'notebook.view.documents.count': '{{count}} Resources', 'notebook.view.documents.add': 'Add', - 'notebook.view.upload.heading': 'Upload a resource to get started', - 'notebook.view.upload.action': 'Upload a resource', + 'notebook.view.upload.heading': 'Add a resource to get started', + 'notebook.view.upload.action': 'Add a resource', 'notebook.view.processing.heading': 'Processing resources...', 'notebook.view.processing.description': 'Your files are being indexed. You can start asking questions once processing is complete.', @@ -80,14 +83,18 @@ export const intelligentAssistantMessages = { 'notebook.upload.failed': '"{{fileName}}" upload failed.', // Notebook upload modal - 'notebook.upload.modal.title': 'Add a resource to Notebook', - 'notebook.upload.modal.dragDropTitle': 'Drag and drop files here', + 'notebook.upload.modal.title': 'Add resources', + 'notebook.upload.modal.dragDropTitle': + 'Drag and drop files here, or click to browse', 'notebook.upload.modal.browseButton': 'Upload', 'notebook.upload.modal.separator': 'or', 'notebook.upload.modal.infoText': 'Accepted file types: .md, .txt, .pdf, .json, .yaml, .log', + 'notebook.upload.modal.supportedFormats': 'Supported formats:', + 'notebook.upload.modal.maxFileSize': 'Maximum file size is 25 MB.', 'notebook.upload.modal.selectedFiles': '{{count}} of {{max}} files selected', 'notebook.upload.modal.addButton': 'Add ({{count}})', + 'notebook.upload.modal.addButtonEmpty': 'Add', 'notebook.upload.modal.removeFile': 'Remove {{fileName}}', 'notebook.upload.error.unsupportedType': 'Upload error: Unsupported file type(s) found. Please upload only supported file types.', @@ -97,10 +104,19 @@ export const intelligentAssistantMessages = { 'Upload error: Maximum of {{max}} files allowed.', // Notebook overwrite modal - 'notebook.overwrite.modal.title': 'Overwrite Files?', + 'notebook.overwrite.modal.title': 'File already exists', + 'notebook.overwrite.modal.title.one': 'File already exists', + 'notebook.overwrite.modal.title.other': 'Files already exist', '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', + '{{duplicateCount}} files already exist in this notebook. {{newCount}} new resources will be added regardless.', + 'notebook.overwrite.modal.description.one': + '{{duplicateCount}} file already exists in this notebook. {{newCount}} new resources will be added regardless.', + 'notebook.overwrite.modal.description.other': + '{{duplicateCount}} files already exist in this notebook. {{newCount}} new resources will be added regardless.', + 'notebook.overwrite.modal.replace': 'Replace existing files', + 'notebook.overwrite.modal.ignore': 'Ignore duplicated files', + 'notebook.overwrite.modal.action': 'Upload ({{count}})', + 'notebook.overwrite.modal.back': 'Back', 'notebook.document.delete': 'Delete', 'notebook.document.delete.title': 'Remove resource?', 'notebook.document.delete.description': diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/__tests__/notebook-upload-runner.test.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/__tests__/notebook-upload-runner.test.ts new file mode 100644 index 00000000000..2d8767f59fb --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/__tests__/notebook-upload-runner.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { runFileUploads } from '../notebook-upload-runner'; + +const createFile = (name: string) => + new File(['content'], name, { type: 'text/plain' }); + +describe('runFileUploads', () => { + const sessionId = 'test-session'; + const files = [createFile('a.txt'), createFile('b.txt')]; + + it('should call onUploading with all files', () => { + const mutation = { + mutateAsync: jest.fn().mockResolvedValue({ document_id: 'doc-1' }), + }; + const onUploading = jest.fn(); + + runFileUploads(mutation, sessionId, files, { onUploading }); + + expect(onUploading).toHaveBeenCalledWith(files); + }); + + it('should call mutateAsync for each file with sessionId', () => { + const mutation = { + mutateAsync: jest.fn().mockResolvedValue({ document_id: 'doc-1' }), + }; + + runFileUploads(mutation, sessionId, files, {}); + + expect(mutation.mutateAsync).toHaveBeenCalledTimes(2); + expect(mutation.mutateAsync).toHaveBeenCalledWith({ + sessionId, + file: files[0], + }); + expect(mutation.mutateAsync).toHaveBeenCalledWith({ + sessionId, + file: files[1], + }); + }); + + it('should call onStarted with fileName and documentId on success', async () => { + const mutation = { + mutateAsync: jest + .fn() + .mockResolvedValueOnce({ document_id: 'doc-a' }) + .mockResolvedValueOnce({ document_id: 'doc-b' }), + }; + const onStarted = jest.fn(); + + runFileUploads(mutation, sessionId, files, { onStarted }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(onStarted).toHaveBeenCalledTimes(2); + expect(onStarted).toHaveBeenCalledWith({ + fileName: 'a.txt', + documentId: 'doc-a', + }); + expect(onStarted).toHaveBeenCalledWith({ + fileName: 'b.txt', + documentId: 'doc-b', + }); + }); + + it('should call onFailed with fileName on error', async () => { + const mutation = { + mutateAsync: jest + .fn() + .mockResolvedValueOnce({ document_id: 'doc-a' }) + .mockRejectedValueOnce(new Error('upload failed')), + }; + const onFailed = jest.fn(); + + runFileUploads(mutation, sessionId, files, { onFailed }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(onFailed).toHaveBeenCalledTimes(1); + expect(onFailed).toHaveBeenCalledWith('b.txt'); + }); + + it('should work with no callbacks provided', () => { + const mutation = { + mutateAsync: jest.fn().mockResolvedValue({ document_id: 'doc-1' }), + }; + + expect(() => runFileUploads(mutation, sessionId, files, {})).not.toThrow(); + expect(mutation.mutateAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/notebook-upload-runner.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/notebook-upload-runner.ts new file mode 100644 index 00000000000..baca941149b --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/notebook-upload-runner.ts @@ -0,0 +1,50 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type UploadMutation = { + mutateAsync: (args: { + sessionId: string; + file: File; + }) => Promise<{ document_id: string }>; +}; + +type UploadCallbacks = { + onUploading?: (files: File[]) => void; + onStarted?: (info: { fileName: string; documentId: string }) => void; + onFailed?: (fileName: string) => void; +}; + +export function runFileUploads( + mutation: UploadMutation, + sessionId: string, + files: File[], + callbacks: UploadCallbacks, +): void { + callbacks.onUploading?.(files); + for (const file of files) { + mutation + .mutateAsync({ sessionId, file }) + .then(data => { + callbacks.onStarted?.({ + fileName: file.name, + documentId: data.document_id, + }); + }) + .catch(() => { + callbacks.onFailed?.(file.name); + }); + } +}