diff --git a/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md new file mode 100644 index 00000000000..d8366e1ec0c --- /dev/null +++ b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor +--- + +implement docked and overlay display modes for Notebook diff --git a/workspaces/intelligent-assistant/e2e-tests/lightspeed.conversation.test.ts b/workspaces/intelligent-assistant/e2e-tests/lightspeed.conversation.test.ts index 7abbd63613f..67bfd75fe42 100644 --- a/workspaces/intelligent-assistant/e2e-tests/lightspeed.conversation.test.ts +++ b/workspaces/intelligent-assistant/e2e-tests/lightspeed.conversation.test.ts @@ -134,13 +134,17 @@ test.describe('Intelligent assistant conversation', () => { await verifySidePanelConversation(sharedPage, translations); }); - test('Verify scroll controls in Conversation', async ({ - browser, - }, testInfo) => { + test('Verify scroll controls in Conversation', async ({}, testInfo) => { await mockChatHistory(sharedPage, demoChatContent); + await sharedPage.reload(); + await sharedPage.locator('.pf-chatbot__messagebox').waitFor({ + state: 'visible', + }); + await sharedPage.waitForSelector('.pf-chatbot__message--bot', { + timeout: 10_000, + }); const message = demoChatContent[0].messages[0].content; - await sendMessage(message, sharedPage, translations, false); const jumpTopButton = sharedPage.getByRole('button', { name: translations['aria.scroll.up'], @@ -154,17 +158,22 @@ test.describe('Intelligent assistant conversation', () => { await jumpTopButton.click(); await sharedPage.waitForTimeout(500); await expect( - sharedPage.locator('span').filter({ hasText: message }), + sharedPage + .locator('.pf-chatbot__message--user') + .filter({ hasText: message }) + .first(), ).toBeVisible(); await verifySidePanelConversation(sharedPage, translations); await expect(jumpBottomButton).toBeVisible(); await jumpBottomButton.click(); - const responseMessage = sharedPage - .locator('div.pf-chatbot__message-response') - .last(); - await expect(responseMessage).toHaveText(/OpenShift deployment/); + await expect( + sharedPage + .locator('div.pf-chatbot__message-response') + .filter({ hasText: /OpenShift deployment/ }) + .first(), + ).toBeVisible(); }); test('Filter and switch conversations', async () => { diff --git a/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts new file mode 100644 index 00000000000..01f66003a18 --- /dev/null +++ b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts @@ -0,0 +1,277 @@ +/* + * 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 { test, expect, type Page } from '@playwright/test'; + +import { NotebookSurfacePage } from './pages/NotebookSurfacePage'; +import type { LightspeedMessages } from './utils/translations'; +import { bootstrapLightspeedE2ePage } from './utils/lightspeedE2eSetup'; +import { + openChatbot, + selectDisplayMode, + type DisplayMode, +} from './pages/LightspeedPage'; +import { + localeNotebookUpload1Path, + NOTEBOOK_SESSION_MAX_DOCUMENTS, +} from './utils/notebooks'; + +async function switchToCompactNotebooks( + page: Page, + t: LightspeedMessages, + mode: DisplayMode, +) { + await page.goto('/'); + await openChatbot(page, t); + await selectDisplayMode(page, t, mode); + await page.getByRole('tab', { name: t['tabs.notebooks'] }).click(); +} + +for (const mode of ['Overlay', 'Dock to window'] as const) { + test.describe(`Notebooks in ${mode} mode`, () => { + test.describe.configure({ mode: 'serial' }); + + let sharedPage: Page; + let translations: LightspeedMessages; + let notebooks: NotebookSurfacePage; + + test.beforeAll(async ({ browser }) => { + const boot = await bootstrapLightspeedE2ePage(browser); + sharedPage = boot.page; + translations = boot.translations; + notebooks = new NotebookSurfacePage(sharedPage, translations); + }); + + test('tabs are visible and notebooks tab selectable', async () => { + await switchToCompactNotebooks(sharedPage, translations, mode); + + await expect( + sharedPage.getByRole('tab', { name: translations['tabs.chat'] }), + ).toBeVisible(); + const notebooksTab = sharedPage.getByRole('tab', { + name: translations['tabs.notebooks'], + }); + await expect(notebooksTab).toBeVisible(); + await expect(notebooksTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('empty notebook list shows create action', async () => { + await notebooks.expectNotebookListHeaderControlsVisible(); + }); + + test('create notebook and verify compact editor layout', async () => { + await notebooks.clickCreateNotebookFromEmptyList(); + + await expect(notebooks.uploadResourceHeading()).toBeVisible(); + await expect(notebooks.uploadResourceActionButton()).toBeVisible(); + }); + + test('header actions visible in compact mode: close, add, sidebar toggle', async () => { + const header = sharedPage.locator('.pf-chatbot__header'); + + await expect( + header.getByRole('button', { + name: translations['notebook.view.close'], + }), + ).toBeVisible(); + await expect( + header.getByRole('button', { + name: translations['notebook.view.documents.add'], + }), + ).toBeVisible(); + + const collapseLabel = translations['notebook.view.sidebar.collapse']; + const expandLabel = translations['notebook.view.sidebar.expand']; + const sidebarToggle = header.getByRole('button', { + name: new RegExp(`${collapseLabel}|${expandLabel}`), + }); + await expect(sidebarToggle).toBeVisible(); + }); + + test('NotebookView topBar close button hidden in compact mode', async () => { + const closeButtons = sharedPage.getByRole('button', { + name: translations['notebook.view.close'], + }); + await expect(closeButtons).toHaveCount(1); + }); + + test('upload modal opens and renders within panel', async () => { + const header = sharedPage.locator('.pf-chatbot__header'); + const addButton = header.getByRole('button', { + name: translations['notebook.view.documents.add'], + }); + await addButton.click(); + + // In compact mode, disablePortal renders the MUI Dialog inline. The + // ChatbotModal already has role="dialog", so scope to the MUI one. + const dialog = sharedPage.locator( + '[role="dialog"][aria-labelledby="add-document-modal-title"]', + ); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await expect(dialog.locator('#add-document-modal-title')).toBeVisible(); + await expect( + dialog.locator( + `text=${translations['notebook.upload.modal.dragDropTitle']}`, + ), + ).toBeVisible(); + + await dialog + .locator('button', { hasText: translations['modal.cancel'] }) + .click(); + }); + + test('sidebar toggle mirrors icon direction', async () => { + const header = sharedPage.locator('.pf-chatbot__header'); + const collapseLabel = translations['notebook.view.sidebar.collapse']; + const expandLabel = translations['notebook.view.sidebar.expand']; + + const toggle = header.getByRole('button', { + name: new RegExp(`${collapseLabel}|${expandLabel}`), + }); + await expect(toggle).toBeVisible(); + + const initialLabel = await toggle.getAttribute('aria-label'); + + await toggle.click(); + await sharedPage.waitForTimeout(300); + + const newLabel = await toggle.getAttribute('aria-label'); + expect(newLabel).not.toBe(initialLabel); + + const expectedLabel = + initialLabel === collapseLabel ? expandLabel : collapseLabel; + expect(newLabel).toBe(expectedLabel); + + await toggle.click(); + await sharedPage.waitForTimeout(300); + const restoredLabel = await toggle.getAttribute('aria-label'); + expect(restoredLabel).toBe(initialLabel); + }); + + test('file picker works in compact upload modal', async ({}, testInfo) => { + const { absolutePath } = localeNotebookUpload1Path(testInfo.project.name); + + const header = sharedPage.locator('.pf-chatbot__header'); + await header + .getByRole('button', { + name: translations['notebook.view.documents.add'], + }) + .click(); + + const dialog = sharedPage.locator( + '[role="dialog"][aria-labelledby="add-document-modal-title"]', + ); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const fileInput = dialog.locator('input[type="file"]'); + await fileInput.setInputFiles([absolutePath]); + + const stagedCaption = translations['notebook.upload.modal.selectedFiles'] + .replace('{{count}}', '1') + .replace('{{max}}', String(NOTEBOOK_SESSION_MAX_DOCUMENTS)); + await expect(dialog.locator(`text=${stagedCaption}`)).toBeVisible({ + timeout: 5_000, + }); + + await dialog + .locator('button', { hasText: translations['modal.cancel'] }) + .click(); + }); + + test('switch tabs preserves notebook state', async () => { + await sharedPage + .getByRole('tab', { name: translations['tabs.chat'] }) + .click(); + await expect( + sharedPage.getByRole('tab', { name: translations['tabs.chat'] }), + ).toHaveAttribute('aria-selected', 'true'); + + await sharedPage + .getByRole('tab', { name: translations['tabs.notebooks'] }) + .click(); + await expect( + sharedPage.getByRole('tab', { + name: translations['tabs.notebooks'], + }), + ).toHaveAttribute('aria-selected', 'true'); + + // Notebook editor still shows (not reverted to list view) + await expect(notebooks.uploadResourceHeading()).toBeVisible(); + }); + + test('close notebook via header action', async () => { + const header = sharedPage.locator('.pf-chatbot__header'); + await header + .getByRole('button', { + name: translations['notebook.view.close'], + }) + .click(); + + await expect(notebooks.myNotebooksHeading()).toBeVisible(); + // Empty notebooks (no uploaded documents) are auto-deleted on close + await expect( + notebooks.createNotebookFromEmptyStateButton(), + ).toBeVisible(); + }); + + test('display mode switch preserves notebooks tab', async () => { + const otherMode: DisplayMode = + mode === 'Overlay' ? 'Dock to window' : 'Overlay'; + + await selectDisplayMode(sharedPage, translations, otherMode); + + await expect( + sharedPage.getByRole('tab', { + name: translations['tabs.notebooks'], + }), + ).toHaveAttribute('aria-selected', 'true'); + + await expect(notebooks.myNotebooksHeading()).toBeVisible(); + }); + + test('switch to fullscreen preserves notebooks tab', async () => { + await selectDisplayMode(sharedPage, translations, 'Fullscreen'); + + await expect( + sharedPage.getByRole('tab', { + name: translations['tabs.notebooks'], + }), + ).toBeVisible(); + }); + + test('cleanup: delete created notebook', async () => { + await selectDisplayMode(sharedPage, translations, mode); + + await expect( + sharedPage.getByRole('tab', { + name: translations['tabs.notebooks'], + }), + ).toBeVisible(); + await sharedPage + .getByRole('tab', { name: translations['tabs.notebooks'] }) + .click(); + + const card = notebooks.newestUntitledNotebookCard(); + if ((await card.count()) > 0) { + await notebooks.notebookCardOverflowMenuButton(card).click(); + await notebooks.deleteNotebookOverflowMenuItem().click(); + const confirmDelete = + notebooks.notebookDeleteConfirmationDialog('Untitled Notebook'); + await confirmDelete.confirmDeletion(); + } + }); + }); +} diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/LightspeedPage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/LightspeedPage.ts index 5fe50e5d3ef..4f9920d9642 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/LightspeedPage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/LightspeedPage.ts @@ -39,7 +39,10 @@ export async function selectDisplayMode( t: LightspeedMessages, mode: DisplayMode, ) { - await page.getByRole('button', { name: t['aria.options.label'] }).click(); + await page + .locator('.pf-chatbot__header') + .getByRole('button', { name: t['aria.options.label'] }) + .click(); const modeMap: Record = { Overlay: t['settings.displayMode.overlay'], 'Dock to window': t['settings.displayMode.docked'], diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookDeleteDialogPage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookDeleteDialogPage.ts index bf05fdbfd70..9f2175b702d 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookDeleteDialogPage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookDeleteDialogPage.ts @@ -26,11 +26,11 @@ export class NotebookDeleteDialogPage { private readonly notebookDisplayName: string, ) {} - /** Dialog anchored by visible notebook title (matches MUI `DeleteNotebookModal` content). */ + /** Dialog anchored by accessible name derived from aria-labelledby (matches MUI `DeleteNotebookModal`). */ dialog(): Locator { - return this.page - .getByRole('dialog') - .filter({ hasText: this.notebookDisplayName }); + return this.page.getByRole('dialog', { + name: new RegExp(this.notebookDisplayName), + }); } deleteNotebookConfirmButton(): Locator { @@ -51,6 +51,11 @@ export class NotebookDeleteDialogPage { } async confirmDeletion(): Promise { - await this.deleteNotebookConfirmButton().click(); + const deleteBtn = this.page.locator( + '#delete-notebook-modal-body ~ div button', + { hasText: this.t['notebooks.delete.action'] }, + ); + await deleteBtn.waitFor({ state: 'visible', timeout: 30_000 }); + await deleteBtn.click({ force: true }); } } diff --git a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts index a977ce97342..6b219252956 100644 --- a/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts +++ b/workspaces/intelligent-assistant/e2e-tests/pages/NotebookSurfacePage.ts @@ -54,14 +54,27 @@ export class NotebookSurfacePage { async gotoFullscreenNotebooksTab(): Promise { await openLightspeed(this.page); - await this.page - .getByRole('button', { name: this.t['aria.options.label'] }) - .click(); - await this.page - .getByRole('menuitem', { - name: this.t['settings.displayMode.fullscreen'], - }) - .click(); + + // Fullscreen is persisted. Re-clicking the menu item still sets the + // notebook mode-switch flag even when display mode does not change, which + // makes the next editor close skip auto-delete of empty untitled notebooks. + const fullscreenTitle = this.page.getByRole('heading', { + name: this.t['chatbox.header.title'], + }); + const alreadyFullscreen = await fullscreenTitle.isVisible(); + + if (!alreadyFullscreen) { + await this.page + .getByRole('button', { name: this.t['aria.options.label'] }) + .click(); + await this.page + .getByRole('menuitem', { + name: this.t['settings.displayMode.fullscreen'], + }) + .click(); + await expect(fullscreenTitle).toBeVisible(); + } + await this.page .getByRole('tab', { name: this.t['tabs.notebooks'] }) .click(); diff --git a/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts index dbe3c07f1aa..189dda499ef 100644 --- a/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts +++ b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +/// import type { Browser, Page } from '@playwright/test'; import { models, conversations, mockedShields } from '../fixtures/responses'; import { openLightspeed, switchToLocale } from './testHelper'; @@ -39,17 +40,33 @@ export type LightspeedE2eBootstrap = { }; async function loginAsGuest(page: Page) { - const enter = page.getByRole('button', { name: 'Enter' }); - await enter.click(); - await page.waitForTimeout(2000); + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const enter = page.getByRole('button', { name: 'Enter' }); + await enter.click(); - if (process.env.APP_MODE !== 'nfs') { - await page - .getByRole('heading', { name: 'Red Hat Catalog' }) - .waitFor({ state: 'visible', timeout: 5_000 }); + try { + if (process.env.APP_MODE !== 'nfs') { + await page + .getByRole('heading', { name: 'Red Hat Catalog' }) + .waitFor({ state: 'visible', timeout: 15_000 }); + } else { + // NFS has no catalog heading. Wait until the guest session is actually + // established — a fixed sleep is not enough when several workers log + // in during the first NFS compile, and English skips switchToLocale. + await enter.waitFor({ state: 'hidden', timeout: 15_000 }); + await page + .getByRole('link', { name: 'Settings' }) + .waitFor({ state: 'visible', timeout: 15_000 }); + } + return; + } catch { + if (attempt === maxAttempts) throw new Error('loginAsGuest failed'); + await page.reload(); + await page.waitForTimeout(2000); + } } } - /** * One logged-in Lightspeed session with the same dev-mode mocks as the legacy * monolithic suite. Each Playwright test file should call this from `beforeAll`. diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts index 69c315d1002..a1143d304d7 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/NotebooksApiClient.ts @@ -235,6 +235,7 @@ export class NotebooksApiClient implements NotebooksAPI { async querySession( sessionId: string, query: string, + options?: { signal?: AbortSignal }, ): Promise> { const baseUrl = await this.getBaseUrl(); const response = await this.fetchApi.fetch( @@ -243,6 +244,7 @@ export class NotebooksApiClient implements NotebooksAPI { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), + signal: options?.signal, }, ); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts index 7c12177db4d..118486a1036 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/api/notebooksApi.ts @@ -57,6 +57,7 @@ export type NotebooksAPI = { querySession: ( sessionId: string, query: string, + options?: { signal?: AbortSignal }, ) => Promise>; }; 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 5957f56bcf1..e68e409c84e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -45,7 +45,6 @@ import { ChatbotFooter, ChatbotHeader, ChatbotHeaderMain, - ChatbotHeaderMenu, ChatbotHeaderTitle, FileDropZone, MessageBar, @@ -54,14 +53,11 @@ import { } from '@patternfly/chatbot'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; import { - Alert, - AlertActionCloseButton, - AlertGroup, - AlertVariant, DropdownItem, Label, MenuToggle, MenuToggleElement, + Button as PfButton, Select, SelectList, SelectOption, @@ -103,6 +99,7 @@ import { useStopConversation, } from '../hooks'; import { useCreateNotebook } from '../hooks/notebooks/useCreateNotebook'; +import { useDeleteNotebook } from '../hooks/notebooks/useDeleteNotebook'; import { useNotebookDocuments } from '../hooks/notebooks/useNotebookDocuments'; import { useRenameNotebookWithAlert } from '../hooks/notebooks/useRenameNotebookWithAlert'; import { useLightspeedDrawerContext } from '../hooks/useLightspeedDrawerContext'; @@ -127,10 +124,17 @@ import { LightspeedChatBoxHeader } from './LightspeedChatBoxHeader'; import { McpServersSettings } from './McpServersSettings'; import { MessageBarModelSelector } from './MessageBarModelSelector'; import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; +import { NotebookHeaderActions } from './notebooks/NotebookHeaderActions'; import { NotebooksTab } from './notebooks/NotebooksTab'; +import { useNotebookStreamStore } from './notebooks/NotebookStreamProvider'; import { NotebookView } from './notebooks/NotebookView'; +import { + SidebarCollapseIcon, + SidebarExpandIcon, +} from './notebooks/SidebarCollapseIcon'; import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; +import { ToastAlertGroup } from './ToastAlertGroup'; const COLLAPSE_PANEL_ICON_SVG = `url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 21V3H14V21H16ZM12 17V7L7 12L12 17Z' fill='black'/%3E%3C/svg%3E") no-repeat center`; @@ -146,8 +150,6 @@ const ConditionalWrapper = ({ const useStyles = makeStyles(theme => ({ body: { - // remove default margin and padding from common elements - // lists excluded for proper formatting '& h1, & h2, & h3, & h4, & h5, & h6, & p, & li': { margin: 0, padding: 0, @@ -163,6 +165,11 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', }, }, + bodyCompact: { + height: '100% !important', + minHeight: '0 !important', + overflow: 'hidden', + }, header: { padding: `${theme.spacing(3)}px ${theme.spacing(3)}px 0 ${theme.spacing( 3, @@ -184,13 +191,24 @@ const useStyles = makeStyles(theme => ({ backgroundColor: 'var(--pf-t--global--background--color--floating--default) !important', }, - headerMenu: { - // align hamburger icon with title - '& .pf-v6-c-button': { - display: 'flex', - alignItems: 'center', + chatHeaderActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + compactDrawerPanel: { + '&.pf-v6-c-drawer__panel': { + width: '100%', + minWidth: '100%', + maxWidth: '100%', + flexBasis: '100%', }, }, + notebookHeaderActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, headerLogo: { width: 48, height: 48, @@ -221,10 +239,14 @@ const useStyles = makeStyles(theme => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', + flexWrap: 'wrap', + gap: theme.spacing(1), marginBottom: theme.spacing(4), }, notebooksHeading: { marginBottom: 0, + whiteSpace: 'nowrap', + fontSize: '1.25rem', }, notebooksHeadingEmpty: { '&&': { @@ -280,6 +302,14 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', }, }, + notebooksGridCompact: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: theme.spacing(2), + width: '100%', + maxWidth: '100%', + paddingBottom: theme.spacing(6), + }, notebookCard: { borderRadius: theme.spacing(1.5), display: 'flex', @@ -430,18 +460,6 @@ const useStyles = makeStyles(theme => ({ backgroundColor: 'var(--pf-t--global--background--color--floating--default) !important', }, - toastAlertGroup: { - '--pf-v6-c-alert-group--m-toast--InsetInlineEnd': `${theme.spacing(2.5)}px`, - '--pf-v6-c-alert-group--m-toast--InsetBlockStart': `${theme.spacing(2.5)}px`, - '--pf-v6-c-alert-group--m-toast--MaxWidth': '350px', - '--pf-v6-c-alert-group--m-toast--ZIndex': '9999', - }, - toastAlert: { - maxWidth: '350px', - '& .pf-v6-c-alert__title': { - margin: 0, - }, - }, // When present, pushes welcome content to bottom (zoom out). Scroll up to see important box (zoom in). chatbotContentSpacer: { flex: 1, @@ -678,6 +696,8 @@ export const LightspeedChat = ({ consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, + activeNotebookId, + setActiveNotebookId, } = useLightspeedDrawerContext(); const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; const location = useLocation(); @@ -688,9 +708,7 @@ export const LightspeedChat = ({ const [filterValue, setFilterValue] = useState(''); const [announcement, setAnnouncement] = useState(''); const [activeTab, setActiveTab] = useState(() => { - if (!isFullscreenMode) { - return 0; - } + // Route matches only occur in fullscreen mode; compact modes don't navigate to /notebooks URLs. if (notebooksRouteMatch || notebookViewRouteMatch) { return 1; } @@ -720,30 +738,25 @@ export const LightspeedChat = ({ null, ); const [deleteNotebookId, setDeleteNotebookId] = useState(null); - const [activeNotebook, setActiveNotebook] = useState( - null, - ); - const { - data: routeNotebook, - isLoading: routeNotebookLoading, - isError: routeNotebookError, - } = useNotebookSession(routeNotebookId); + const effectiveNotebookId = + routeNotebookId ?? (!isFullscreenMode ? activeNotebookId : undefined); + const { data: activeNotebook, isError: activeNotebookError } = + useNotebookSession(effectiveNotebookId); useEffect(() => { - if (routeNotebookId && routeNotebook && !routeNotebookLoading) { - setActiveNotebook(routeNotebook); - } else if (routeNotebookId && routeNotebookError) { - navigate(`${LIGHTSPEED_PATH}/notebooks`, { replace: true }); - } else if (!routeNotebookId && notebooksRouteMatch) { - setActiveNotebook(null); + if (effectiveNotebookId && activeNotebookError) { + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks`, { replace: true }); + } else { + setActiveNotebookId(undefined); + } } }, [ - routeNotebookId, - routeNotebook, - routeNotebookLoading, - routeNotebookError, - notebooksRouteMatch, + effectiveNotebookId, + activeNotebookError, + isFullscreenMode, navigate, + setActiveNotebookId, ]); const [notebookAlerts, setNotebookAlerts] = useState[]>( @@ -758,6 +771,13 @@ export const LightspeedChat = ({ }); const { data: notebookDocuments = [], isFetching: isDocumentsFetching } = useNotebookDocuments(activeNotebook?.session_id); + const [notebookUploadsInProgress, setNotebookUploadsInProgress] = + useState(false); + const { mutate: deleteNotebook } = useDeleteNotebook(); + const notebookStreamStore = useNotebookStreamStore(); + const [notebookSidebarCollapsed, setNotebookSidebarCollapsed] = + useState(!isFullscreenMode); + const [notebookUploadModalOpen, setNotebookUploadModalOpen] = useState(false); const [conversationId, setConversationId] = useState(''); const [requestId, setRequestId] = useState(''); const [newChatCreated, setNewChatCreated] = useState(false); @@ -776,7 +796,7 @@ export const LightspeedChat = ({ const wasStoppedByUserRef = useRef(false); const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = useLastOpenedConversation(user); - const showChatPanel = !isFullscreenMode || activeTab === 0; + const showChatPanel = activeTab === 0; const showNotebooksPanel = (notebooksEnabled || isOnNotebookRoute) && activeTab !== 0; const [isChatHistoryDrawerOpen, setIsChatHistoryDrawerOpen] = @@ -811,59 +831,124 @@ export const LightspeedChat = ({ setShellViewTab, ]); + // Auto-delete the currently active notebook when the user leaves it, but only + // if it is still an empty untitled "scratch" notebook (no documents, no + // uploads, no conversation). Called explicitly from the leave points (close, + // tab switch, notebook switch) so display-mode switches never trigger it. + const maybeAutoDeleteScratchNotebook = useCallback(() => { + const nb = activeNotebook; + if (!nb) return; + const isUntitled = nb.name === UNTITLED_NOTEBOOK_NAME; + const isEmpty = notebookDocuments.length === 0; + const noUploads = !notebookUploadsInProgress; + const convId = nb.metadata?.conversation_id; + const noChat = !convId || convId === TEMP_CONVERSATION_ID; + if (isUntitled && isEmpty && noUploads && noChat) { + notebookStreamStore.clear(nb.session_id); + deleteNotebook(nb.session_id); + } + }, [ + activeNotebook, + notebookDocuments, + notebookUploadsInProgress, + notebookStreamStore, + deleteNotebook, + ]); + const handleNotebookTabSelect = (_event: SyntheticEvent, nextTab: number) => { + if (nextTab === 0) { + maybeAutoDeleteScratchNotebook(); + } setActiveTab(nextTab); setShellViewTab(nextTab); - if (nextTab === 1) { - navigate(`${LIGHTSPEED_PATH}/notebooks`); - if (notebooksPermissionResolved) { - refetchNotebooks(); + if (isFullscreenMode) { + if (nextTab === 1) { + navigate( + activeNotebookId + ? `${LIGHTSPEED_PATH}/notebooks/${activeNotebookId}` + : `${LIGHTSPEED_PATH}/notebooks`, + ); + } else { + navigate( + routeConversationId + ? `${LIGHTSPEED_PATH}/conversation/${routeConversationId}` + : LIGHTSPEED_PATH, + ); } - } else { - setActiveNotebook(null); - navigate( - routeConversationId - ? `${LIGHTSPEED_PATH}/conversation/${routeConversationId}` - : LIGHTSPEED_PATH, - ); + } + if (nextTab === 1 && notebooksPermissionResolved) { + refetchNotebooks(); } }; const setDisplayModeFromHeader = useCallback( (mode: ChatbotDisplayMode) => { if (mode !== ChatbotDisplayMode.embedded) { + if (activeTab === 1) { + const notebookId = routeNotebookId || activeNotebookId; + if (notebookId) { + setActiveNotebookId(notebookId); + } + } setDisplayMode(mode); return; } if (activeTab === 1) { - const sid = activeNotebook?.session_id; setDisplayMode( mode, undefined, - sid ? { notebookSessionId: sid } : 'notebooks', + activeNotebookId + ? { notebookSessionId: activeNotebookId } + : 'notebooks', ); } else { setDisplayMode(mode); } }, - [setDisplayMode, activeTab, activeNotebook?.session_id], + [ + setDisplayMode, + activeTab, + routeNotebookId, + activeNotebookId, + setActiveNotebookId, + ], ); const handleCreateNotebook = useCallback(() => { + maybeAutoDeleteScratchNotebook(); createNotebookMutation.mutate( { name: UNTITLED_NOTEBOOK_NAME }, { onSuccess: (session: NotebookSession) => { - navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); + setActiveNotebookId(session.session_id); + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); + } }, }, ); - }, [createNotebookMutation, navigate]); + }, [ + maybeAutoDeleteScratchNotebook, + createNotebookMutation, + isFullscreenMode, + navigate, + setActiveNotebookId, + ]); const handleCloseNotebook = useCallback(() => { - setActiveNotebook(null); - navigate(`${LIGHTSPEED_PATH}/notebooks`); - }, [navigate]); + maybeAutoDeleteScratchNotebook(); + setActiveNotebookId(undefined); + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks`); + } + refetchNotebooks(); + }, [ + maybeAutoDeleteScratchNotebook, + isFullscreenMode, + navigate, + refetchNotebooks, + setActiveNotebookId, + ]); const handleRemoveNotebookAlert = (key: React.Key) => { setNotebookAlerts(prevAlerts => @@ -872,6 +957,9 @@ export const LightspeedChat = ({ }; const handleNotebookDeleted = () => { + if (deleteNotebookId) { + notebookStreamStore.clear(deleteNotebookId); + } const key = Date.now(); setNotebookAlerts(prevAlerts => [ { title: t('notebooks.delete.toast'), variant: 'success', key }, @@ -1382,6 +1470,7 @@ export const LightspeedChat = ({ (_: MouseEvent | undefined, selectedItem: string | number | undefined) => { if (!isFullscreenMode) { setIsMcpSettingsOpen(false); + setIsChatHistoryDrawerOpen(false); } setNewChatCreated(false); const newConvId = String(selectedItem); @@ -1880,32 +1969,10 @@ export const LightspeedChat = ({ return ( <> - {notebookAlerts.length > 0 && ( - - {notebookAlerts.map(({ key, title, variant }) => ( - handleRemoveNotebookAlert(key as React.Key)} - actionClose={ - handleRemoveNotebookAlert(key as React.Key)} - /> - } - /> - ))} - - )} + {isDeleteModalOpen && ( n.session_id === deleteNotebookId)?.name ?? '' } + isCompact={!isFullscreenMode} /> )} {showChatPanel && !isFullscreenMode && ( - + + + {isChatHistoryDrawerOpen ? ( + + ) : ( + + )} + + + {!isChatHistoryDrawerOpen && ( + + + + + + )} + + )} + {!isFullscreenMode && showNotebooksPanel && activeNotebook && ( + setNotebookUploadModalOpen(true)} + uploadsInProgress={notebookUploadsInProgress} + uploadModalOpen={notebookUploadModalOpen} + sidebarCollapsed={notebookSidebarCollapsed} + onSidebarCollapsedChange={setNotebookSidebarCollapsed} /> )} {isFullscreenMode && ( @@ -1985,8 +2105,10 @@ export const LightspeedChat = ({ onMcpSettingsClick={() => setIsMcpSettingsOpen(true)} /> - {isFullscreenMode &&
} - {isFullscreenMode && shouldShowTabs && ( + {(isFullscreenMode || shouldShowTabs) && ( +
+ )} + {shouldShowTabs && ( )} {showNotebooksPanel && !notebooksPermissionLoading && hasNotebooksAccess && !activeNotebook && ( - { - navigate(`${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`); +
+ > + { + maybeAutoDeleteScratchNotebook(); + setActiveNotebookId(notebook.session_id); + if (isFullscreenMode) { + navigate( + `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, + ); + } + }} + onRename={handleRenameNotebook} + onDelete={setDeleteNotebookId} + onCreateNotebook={handleCreateNotebook} + t={t} + /> +
)} {showNotebooksPanel && !notebooksPermissionLoading && diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerContext.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerContext.tsx index 0f54b922799..601d31bc1d0 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerContext.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerContext.tsx @@ -50,8 +50,8 @@ export interface LightspeedDrawerContextType { * Set the display mode (overlay, docked, or fullscreen/embedded). * When entering embedded mode, optional `embeddedNotebooks` navigates to * `/lightspeed/notebooks` (or a session URL) instead of the chat route. - * Leaving embedded for overlay or docked resets the shell tab to Chat - * (Notebooks is only available in fullscreen). + * Notebooks are available in all display modes; the shell tab and active + * notebook are preserved across mode switches. */ setDisplayMode: ( mode: ChatbotDisplayMode, @@ -105,6 +105,13 @@ export interface LightspeedDrawerContextType { */ shellViewTab: number; setShellViewTab: (tab: number) => void; + /** + * ID of the currently active notebook session, persisted across + * overlay/docked/fullscreen remounts so display-mode switches preserve + * the open notebook. + */ + activeNotebookId: string | undefined; + setActiveNotebookId: (id: string | undefined) => void; } const CONTEXT_KEY = '__lightspeed_drawer_context__' as keyof typeof globalThis; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx index 560c490f541..87099af8fdf 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx @@ -23,6 +23,7 @@ import { DOCKED_CONTENT_OFFSET } from '../const'; import { useLightspeedProviderState } from '../hooks/useLightspeedProviderState'; import { LightspeedChatContainer } from './LightspeedChatContainer'; import { LightspeedDrawerContext } from './LightspeedDrawerContext'; +import { NotebookStreamProvider } from './notebooks/NotebookStreamProvider'; const useStyles = makeStyles(theme => ({ chatbotModal: { @@ -31,7 +32,7 @@ const useStyles = makeStyles(theme => ({ bottom: `calc(${theme?.spacing?.(2) ?? '16px'} + 5em)`, right: `calc(${theme?.spacing?.(2) ?? '16px'} + 1.5em)`, maxWidth: 'min(30rem, calc(100vw - 32px)) !important', - overflowX: 'hidden' as const, + overflow: 'hidden' as const, transition: 'margin-right 0.3s ease', 'body.docked-drawer-open &': { marginRight: DOCKED_CONTENT_OFFSET, @@ -49,20 +50,22 @@ export const LightspeedDrawerProvider = ({ children }: PropsWithChildren) => { return ( - {children} - {shouldRenderOverlayModal && ( - closeChatbot()} - ouiaId="LightspeedChatbotModal" - aria-labelledby="lightspeed-chatpopup-modal" - className={classes.chatbotModal} - > - - - )} + + {children} + {shouldRenderOverlayModal && ( + closeChatbot()} + ouiaId="LightspeedChatbotModal" + aria-labelledby="lightspeed-chatpopup-modal" + className={classes.chatbotModal} + > + + + )} + ); }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx new file mode 100644 index 00000000000..1421da0c3ea --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx @@ -0,0 +1,82 @@ +/* + * 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 React from 'react'; + +import { makeStyles } from '@material-ui/core'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, + AlertVariant, + type AlertProps, +} from '@patternfly/react-core'; + +const useStyles = makeStyles(theme => ({ + toastAlertGroup: { + '--pf-v6-c-alert-group--m-toast--InsetInlineEnd': `${theme.spacing(2.5)}px`, + '--pf-v6-c-alert-group--m-toast--InsetBlockStart': `${theme.spacing(2.5)}px`, + '--pf-v6-c-alert-group--m-toast--MaxWidth': '350px', + '--pf-v6-c-alert-group--m-toast--ZIndex': '9999', + }, + toastAlert: { + maxWidth: '350px', + '& .pf-v6-c-alert__title': { + margin: 0, + }, + }, +})); + +type ToastAlertGroupProps = { + alerts: Partial[]; + onRemoveAlert: (key: React.Key) => void; +}; + +export const ToastAlertGroup = ({ + alerts, + onRemoveAlert, +}: ToastAlertGroupProps) => { + const classes = useStyles(); + + if (alerts.length === 0) return null; + + return ( + + {alerts.map(({ key, title, variant }) => ( + onRemoveAlert(key as React.Key)} + actionClose={ + onRemoveAlert(key as React.Key)} + /> + } + /> + ))} + + ); +}; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx index 78e3d9b6486..87b7d3d34bc 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx @@ -42,6 +42,7 @@ import { useLightspeedDrawerContext } from '../../hooks/useLightspeedDrawerConte import { mockUseTranslation } from '../../test-utils/mockTranslations'; import FileAttachmentContextProvider from '../AttachmentContext'; import { LightspeedChat } from '../LightSpeedChat'; +import { NotebookStreamProvider } from '../notebooks/NotebookStreamProvider'; const identityApi = { async getCredentials() { @@ -209,16 +210,18 @@ const setupLightspeedChat = (initialPath = '/intelligent-assistant') => ( > - {}} - topicRestrictionEnabled={false} - selectedProvider="openai" - models={[]} - avatar="test" - userName="user:test" - /> + + {}} + topicRestrictionEnabled={false} + selectedProvider="openai" + models={[]} + avatar="test" + userName="user:test" + /> + @@ -258,6 +261,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); localStorage.clear(); @@ -677,6 +682,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 1, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant/notebooks')); @@ -701,7 +708,7 @@ describe('LightspeedChat', () => { ); }); - it('should not render Chat/Notebooks tabs in overlay mode', async () => { + it('should render Chat/Notebooks tabs in overlay mode', async () => { mockUseLightspeedDrawerContext.mockReturnValue({ isChatbotActive: true, toggleChatbot: jest.fn(), @@ -718,6 +725,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -726,15 +735,13 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); + expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( - screen.queryByRole('tab', { name: 'Chat' }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('tab', { name: 'Notebooks' }), - ).not.toBeInTheDocument(); + screen.getByRole('tab', { name: 'Notebooks' }), + ).toBeInTheDocument(); }); - it('should not render Chat/Notebooks tabs in docked mode', async () => { + it('should render Chat/Notebooks tabs in docked mode', async () => { mockUseLightspeedDrawerContext.mockReturnValue({ isChatbotActive: true, toggleChatbot: jest.fn(), @@ -751,6 +758,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -759,12 +768,10 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); + expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( - screen.queryByRole('tab', { name: 'Chat' }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('tab', { name: 'Notebooks' }), - ).not.toBeInTheDocument(); + screen.getByRole('tab', { name: 'Notebooks' }), + ).toBeInTheDocument(); }); it('should show current display mode as selected in full-screen mode', async () => { @@ -784,6 +791,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -818,6 +827,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -852,6 +863,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -946,6 +959,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); }); @@ -982,6 +997,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 1, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant')); @@ -1057,6 +1074,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant/notebooks')); @@ -1092,6 +1111,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerProvider.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerProvider.test.tsx index 363edffefb3..911e7aff0c3 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerProvider.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerProvider.test.tsx @@ -99,6 +99,8 @@ function baseContextValue(): LightspeedDrawerContextType { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }; } diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx index 73995ebf7c7..0b94799cc70 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedDrawerStateExposer.test.tsx @@ -43,6 +43,8 @@ describe('LightspeedDrawerStateExposer', () => { setDraftFileContents: jest.fn(), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), ...overrides, }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx index 67af1716dd3..3027100a9b4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedFAB.test.tsx @@ -43,6 +43,8 @@ describe('LightspeedFAB', () => { setDraftFileContents: jest.fn(), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), ...overrides, }); 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 1a3958dc49a..a5084f0f4a3 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 @@ -47,35 +47,22 @@ import { getNotebookAcceptedFileTypes, validateFiles, } from '../../utils/notebook-upload-utils'; +import { getScopedDialogProps } from '../../utils/scoped-dialog-utils'; import { FileListItem } from './FileListItem'; +import { notebookDialogStyles } from './notebookDialogStyles'; 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, - maxWidth: 578, - }, - dialogTitle: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '24px 24px 16px', - }, - titleText: { - fontWeight: 500, - fontSize: '1.25rem', - lineHeight: '1.625rem', + ...notebookDialogStyles(theme), + titleTextCompact: { + fontWeight: 600, + fontSize: '1.125rem', + lineHeight: '1.5rem', letterSpacing: '-0.25px', }, - closeButton: { - color: theme.palette.text.primary, - }, - dialogContent: { - padding: '0 24px 24px', - }, errorAlert: { marginBottom: theme.spacing(2), '--pf-v6-c-alert--PaddingBlockEnd': '0', @@ -123,6 +110,11 @@ const useStyles = makeStyles(theme => ({ justifyContent: 'flex-start', gap: theme.spacing(1), }, + dialogActionsCompact: { + padding: '12px 16px !important', + justifyContent: 'flex-start', + gap: theme.spacing(1), + }, addButton: { textTransform: 'none', }, @@ -218,6 +210,7 @@ type AddDocumentModalProps = { onDuplicatesFound?: (duplicateFiles: File[], allFiles: File[]) => void; filesToAdd?: File[]; onFilesAdded?: () => void; + isCompact?: boolean; }; export const AddDocumentModal = ({ @@ -232,13 +225,13 @@ export const AddDocumentModal = ({ onDuplicatesFound, filesToAdd, onFilesAdded, + isCompact = false, }: AddDocumentModalProps) => { const classes = useStyles(); 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; @@ -319,17 +312,26 @@ export const AddDocumentModal = ({ onClose(); }; + const scopedProps = getScopedDialogProps(isCompact); + return ( - - + + {t('notebook.upload.modal.title')} {selectedFiles.length > 0 && ` (${selectedFiles.length}/${NOTEBOOK_MAX_FILES - existingDocumentNames.length})`} @@ -344,7 +346,11 @@ export const AddDocumentModal = ({ - + {validationErrors.length > 0 && ( - + -
+ {!isCompact && ( +
+ +
+ )}
{renderMainContent()}
@@ -898,14 +827,14 @@ export const NotebookView = ({ {(() => { const addResourceAction = ( - + + ); return hasNoDocuments ? (
) : ( f.name)} + isCompact={isCompact} /> setDeleteDocumentTarget(null)} onConfirm={confirmDeleteDocument} documentName={deleteDocumentTarget?.name ?? ''} + isCompact={isCompact} />
); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebooksTab.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebooksTab.tsx index 264a9367d4b..3a9b3c4597d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebooksTab.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebooksTab.tsx @@ -18,7 +18,7 @@ import type { TranslationFunction } from '@backstage/core-plugin-api/alpha'; import { Typography } from '@material-ui/core'; import { Button } from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; +import { AddCircleOIcon } from '@patternfly/react-icons'; import { CatalogIcon } from '@patternfly/react-icons/dist/esm/icons'; import { intelligentAssistantTranslationRef } from '../../translations/ref'; @@ -59,7 +59,7 @@ export const NotebooksTab = ({