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/utils/lightspeedE2eSetup.ts b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts index dbe3c07f1aa..c64e10d4ae3 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,28 @@ 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(); + await page.waitForTimeout(2000); - if (process.env.APP_MODE !== 'nfs') { - await page - .getByRole('heading', { name: 'Red Hat Catalog' }) - .waitFor({ state: 'visible', timeout: 5_000 }); + if (process.env.APP_MODE !== 'nfs') { + try { + await page + .getByRole('heading', { name: 'Red Hat Catalog' }) + .waitFor({ state: 'visible', timeout: 10_000 }); + return; + } catch { + if (attempt === maxAttempts) throw new Error('loginAsGuest failed'); + await page.reload(); + await page.waitForTimeout(2000); + } + } else { + return; + } } } - /** * 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/components/LightSpeedChat.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx index 5ef597e663c..907a620e7b6 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -62,6 +62,7 @@ import { Label, MenuToggle, MenuToggleElement, + Button as PFButton, Select, SelectList, SelectOption, @@ -71,11 +72,13 @@ import { } from '@patternfly/react-core'; import { PenIcon, + PlusCircleIcon, PlusIcon, SearchIcon, SortAmountDownAltIcon, SortAmountDownIcon, ThumbtackIcon, + TimesIcon, TrashIcon, } from '@patternfly/react-icons'; import { RhUiAiExperienceIcon } from '@patternfly/react-icons/dist/esm/icons/rh-ui-ai-experience-icon'; @@ -127,8 +130,15 @@ import { McpServersSettings } from './McpServersSettings'; import { MessageBarModelSelector } from './MessageBarModelSelector'; import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; import { NotebooksTab } from './notebooks/NotebooksTab'; -import { NotebookView } from './notebooks/NotebookView'; +import { + NotebookView, + type NotebookViewHandle, +} from './notebooks/NotebookView'; import { RenameNotebookModal } from './notebooks/RenameNotebookModal'; +import { + SidebarCollapseIcon, + SidebarExpandIcon, +} from './notebooks/SidebarCollapseIcon'; import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; @@ -146,8 +156,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 +171,16 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', }, }, + bodyCompact: { + height: '100% !important', + minHeight: '0 !important', + overflow: 'hidden', + '& .pf-chatbot-container': { + minHeight: '0 !important', + display: 'flex', + flexDirection: 'column', + }, + }, header: { padding: `${theme.spacing(3)}px ${theme.spacing(3)}px 0 ${theme.spacing( 3, @@ -191,6 +209,11 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', }, }, + notebookHeaderActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, headerLogo: { width: 48, height: 48, @@ -221,10 +244,13 @@ 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', }, notebooksHeadingEmpty: { '&&': { @@ -280,6 +306,14 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', }, }, + notebooksGridCompact: { + display: 'grid', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + gap: theme.spacing(2), + width: '100%', + maxWidth: '100%', + paddingBottom: theme.spacing(3), + }, notebookCard: { borderRadius: theme.spacing(1.5), display: 'flex', @@ -666,6 +700,8 @@ export const LightspeedChat = ({ consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, + activeNotebookId, + setActiveNotebookId, } = useLightspeedDrawerContext(); const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; const location = useLocation(); @@ -676,9 +712,6 @@ export const LightspeedChat = ({ const [filterValue, setFilterValue] = useState(''); const [announcement, setAnnouncement] = useState(''); const [activeTab, setActiveTab] = useState(() => { - if (!isFullscreenMode) { - return 0; - } if (notebooksRouteMatch || notebookViewRouteMatch) { return 1; } @@ -709,35 +742,51 @@ export const LightspeedChat = ({ const [activeNotebook, setActiveNotebook] = useState( null, ); + const effectiveNotebookId = + routeNotebookId || (!isFullscreenMode ? activeNotebookId : undefined); const { data: routeNotebook, isLoading: routeNotebookLoading, isError: routeNotebookError, - } = useNotebookSession(routeNotebookId); + } = useNotebookSession(effectiveNotebookId); useEffect(() => { - if (routeNotebookId && routeNotebook && !routeNotebookLoading) { + if (effectiveNotebookId && routeNotebook && !routeNotebookLoading) { setActiveNotebook(routeNotebook); - } else if (routeNotebookId && routeNotebookError) { - navigate(`${LIGHTSPEED_PATH}/notebooks`, { replace: true }); - } else if (!routeNotebookId && notebooksRouteMatch) { + setActiveNotebookId(routeNotebook.session_id); + } else if (effectiveNotebookId && routeNotebookError) { + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks`, { replace: true }); + } else { + setActiveNotebook(null); + setActiveNotebookId(undefined); + } + } else if (!effectiveNotebookId && notebooksRouteMatch) { setActiveNotebook(null); } }, [ - routeNotebookId, + effectiveNotebookId, routeNotebook, routeNotebookLoading, routeNotebookError, notebooksRouteMatch, + isFullscreenMode, navigate, + setActiveNotebookId, ]); const [notebookAlerts, setNotebookAlerts] = useState[]>( [], ); const createNotebookMutation = useCreateNotebook(); + const notebookViewRef = useRef(null); const { data: notebookDocuments = [], isFetching: isDocumentsFetching } = useNotebookDocuments(activeNotebook?.session_id); + const [notebookUploadsInProgress, setNotebookUploadsInProgress] = + useState(false); + const [notebookSidebarCollapsed, setNotebookSidebarCollapsed] = + useState(true); + const [notebookUploadModalOpen, setNotebookUploadModalOpen] = useState(false); const [conversationId, setConversationId] = useState(''); const [requestId, setRequestId] = useState(''); const [newChatCreated, setNewChatCreated] = useState(false); @@ -756,7 +805,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] = @@ -794,17 +843,19 @@ export const LightspeedChat = ({ const handleNotebookTabSelect = (_event: SyntheticEvent, nextTab: number) => { setActiveTab(nextTab); setShellViewTab(nextTab); - if (nextTab === 1) { - navigate(`${LIGHTSPEED_PATH}/notebooks`); - if (notebooksPermissionResolved) { - refetchNotebooks(); + if (isFullscreenMode) { + if (nextTab === 1) { + navigate(`${LIGHTSPEED_PATH}/notebooks`); + } else { + navigate( + routeConversationId + ? `${LIGHTSPEED_PATH}/conversation/${routeConversationId}` + : LIGHTSPEED_PATH, + ); } - } else { - navigate( - routeConversationId - ? `${LIGHTSPEED_PATH}/conversation/${routeConversationId}` - : LIGHTSPEED_PATH, - ); + } + if (nextTab === 1 && notebooksPermissionResolved) { + refetchNotebooks(); } }; @@ -833,16 +884,26 @@ export const LightspeedChat = ({ { name: UNTITLED_NOTEBOOK_NAME }, { onSuccess: (session: NotebookSession) => { - navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); + } else { + setActiveNotebook(session); + setActiveNotebookId(session.session_id); + } }, }, ); - }, [createNotebookMutation, navigate]); + }, [createNotebookMutation, isFullscreenMode, navigate, setActiveNotebookId]); const handleCloseNotebook = useCallback(() => { - navigate(`${LIGHTSPEED_PATH}/notebooks`); + if (isFullscreenMode) { + navigate(`${LIGHTSPEED_PATH}/notebooks`); + } else { + setActiveNotebook(null); + setActiveNotebookId(undefined); + } refetchNotebooks(); - }, [navigate, refetchNotebooks]); + }, [isFullscreenMode, navigate, refetchNotebooks, setActiveNotebookId]); const handleRemoveNotebookAlert = (key: React.Key) => { setNotebookAlerts(prevAlerts => @@ -1909,22 +1970,12 @@ export const LightspeedChat = ({ currentName={ notebooks.find(n => n.session_id === renameNotebookId)?.name ?? '' } - /> - )} - {deleteNotebookId && ( - setDeleteNotebookId(null)} - onDeleted={handleNotebookDeleted} - sessionId={deleteNotebookId} - name={ - notebooks.find(n => n.session_id === deleteNotebookId)?.name ?? '' - } + isCompact={!isFullscreenMode} /> )} )} + {!isFullscreenMode && showNotebooksPanel && activeNotebook && ( + + + + + + + + notebookViewRef.current?.openUploadModal()} + aria-label={t('notebook.view.documents.add')} + size="sm" + isDisabled={notebookUploadsInProgress} + > + + + + + notebookViewRef.current?.toggleSidebar()} + aria-label={ + notebookSidebarCollapsed + ? t('notebook.view.sidebar.expand') + : t('notebook.view.sidebar.collapse') + } + size="sm" + > + {notebookSidebarCollapsed ? ( + + ) : ( + + )} + + + + )} {isFullscreenMode && ( <> setIsMcpSettingsOpen(true)} /> - {isFullscreenMode && } - {isFullscreenMode && shouldShowTabs && ( + {(isFullscreenMode || shouldShowTabs) && ( + + )} + {shouldShowTabs && ( )} {showNotebooksPanel && !notebooksPermissionLoading && hasNotebooksAccess && !activeNotebook && ( - { - navigate(`${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`); + + > + { + if (isFullscreenMode) { + navigate( + `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, + ); + } else { + setActiveNotebook(notebook); + setActiveNotebookId(notebook.session_id); + } + }} + onRename={setRenameNotebookId} + onDelete={setDeleteNotebookId} + onCreateNotebook={handleCreateNotebook} + t={t} + /> + {deleteNotebookId && ( + setDeleteNotebookId(null)} + onDeleted={handleNotebookDeleted} + sessionId={deleteNotebookId} + name={ + notebooks.find(n => n.session_id === deleteNotebookId) + ?.name ?? '' + } + isCompact={!isFullscreenMode} + /> + )} + )} {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..3e7eb5f1db2 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightspeedDrawerProvider.tsx @@ -31,7 +31,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, 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..d210386d104 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 @@ -258,6 +258,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); localStorage.clear(); @@ -677,6 +679,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 1, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant/notebooks')); @@ -701,7 +705,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 +722,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -726,15 +732,13 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); - expect( - screen.queryByRole('tab', { name: 'Chat' }), - ).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( screen.queryByRole('tab', { name: 'Notebooks' }), - ).not.toBeInTheDocument(); + ).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 +755,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -759,12 +765,10 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); - expect( - screen.queryByRole('tab', { name: 'Chat' }), - ).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( screen.queryByRole('tab', { name: 'Notebooks' }), - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); it('should show current display mode as selected in full-screen mode', async () => { @@ -784,6 +788,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -818,6 +824,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -852,6 +860,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat()); @@ -946,6 +956,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); }); @@ -982,6 +994,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 1, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant')); @@ -1057,6 +1071,8 @@ describe('LightspeedChat', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }); render(setupLightspeedChat('/intelligent-assistant/notebooks')); @@ -1092,6 +1108,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 7b85fbb5946..004473e2718 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 @@ -41,6 +41,7 @@ import { getNotebookAcceptedFileTypes, validateFiles, } from '../../utils/notebook-upload-utils'; +import { getScopedDialogProps } from '../../utils/scoped-dialog-utils'; import { FileListItem } from './FileListItem'; const useStyles = makeStyles(theme => ({ @@ -48,24 +49,43 @@ const useStyles = makeStyles(theme => ({ borderRadius: 24, maxWidth: 578, }, + dialogPaperCompact: { + borderRadius: 12, + maxWidth: '100%', + }, dialogTitle: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px 24px 16px', }, + dialogTitleCompact: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 16px 12px !important', + }, titleText: { fontWeight: 500, fontSize: '1.25rem', lineHeight: '1.625rem', letterSpacing: '-0.25px', }, + titleTextCompact: { + fontWeight: 600, + fontSize: '1.125rem', + lineHeight: '1.5rem', + letterSpacing: '-0.25px', + }, closeButton: { color: theme.palette.text.primary, }, dialogContent: { padding: '0 24px 24px', }, + dialogContentCompact: { + padding: '0 16px 16px !important', + }, errorAlert: { marginBottom: theme.spacing(2), }, @@ -100,6 +120,11 @@ const useStyles = makeStyles(theme => ({ justifyContent: 'flex-end', gap: theme.spacing(1), }, + dialogActionsCompact: { + padding: '12px 16px !important', + justifyContent: 'flex-end', + gap: theme.spacing(1), + }, addButton: { textTransform: 'none', }, @@ -120,6 +145,7 @@ type AddDocumentModalProps = { onDuplicatesFound?: (files: File[]) => void; filesToAdd?: File[]; onFilesAdded?: () => void; + isCompact?: boolean; }; export const AddDocumentModal = ({ @@ -134,13 +160,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; @@ -224,17 +250,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})`} @@ -249,7 +284,11 @@ export const AddDocumentModal = ({ - + {validationErrors.length > 0 && ( {validationErrors @@ -315,7 +354,11 @@ export const AddDocumentModal = ({ )} - + ({ dialogPaper: { borderRadius: 16, }, + dialogPaperCompact: { + borderRadius: 12, + }, dialogTitle: { padding: '16px 20px', fontStyle: 'inherit', }, + dialogTitleCompact: { + padding: '12px 16px !important', + fontStyle: 'inherit', + }, dialogContent: { paddingTop: 0, }, + dialogContentCompact: { + paddingTop: '0 !important', + paddingBottom: `${theme.spacing(1)}px !important`, + paddingLeft: `${theme.spacing(2)}px !important`, + paddingRight: `${theme.spacing(2)}px !important`, + }, titleRow: { display: 'flex', alignItems: 'center', @@ -58,6 +72,11 @@ const useStyles = makeStyles(theme => ({ padding: theme.spacing(2.5), gap: theme.spacing(1), }, + dialogActionsCompact: { + justifyContent: 'left', + padding: `${theme.spacing(1.5)}px !important`, + gap: theme.spacing(1), + }, removeButton: { textTransform: 'none', borderRadius: 999, @@ -73,6 +92,7 @@ type DeleteDocumentModalProps = { onClose: () => void; onConfirm: () => void; documentName: string; + isCompact?: boolean; }; export const DeleteDocumentModal = ({ @@ -80,7 +100,9 @@ export const DeleteDocumentModal = ({ onClose, onConfirm, documentName, + isCompact = false, }: DeleteDocumentModalProps) => { + const scopedProps = getScopedDialogProps(isCompact); const classes = useStyles(); const { t } = useTranslation(); @@ -91,11 +113,15 @@ export const DeleteDocumentModal = ({ aria-labelledby="delete-document-modal" aria-describedby="delete-document-modal-body" fullWidth + {...scopedProps} PaperProps={{ - className: classes.dialogPaper, + className: isCompact ? classes.dialogPaperCompact : classes.dialogPaper, + ...scopedProps.PaperProps, }} > - + {t('notebook.document.delete.title')} @@ -104,7 +130,7 @@ export const DeleteDocumentModal = ({ aria-label="close" onClick={onClose} title={t('common.close')} - size="large" + size={isCompact ? 'small' : 'large'} className={classes.closeButton} > @@ -113,7 +139,9 @@ export const DeleteDocumentModal = ({ - + ({ dialogPaper: { borderRadius: 16, }, + dialogPaperCompact: { + borderRadius: 12, + }, dialogTitle: { padding: '16px 20px', fontStyle: 'inherit', }, + dialogTitleCompact: { + padding: '12px 16px !important', + fontStyle: 'inherit', + }, dialogContent: { paddingTop: 0, paddingBottom: theme.spacing(5), }, + dialogContentCompact: { + paddingTop: '0 !important', + paddingBottom: `${theme.spacing(2)}px !important`, + paddingLeft: `${theme.spacing(2)}px !important`, + paddingRight: `${theme.spacing(2)}px !important`, + }, titleRow: { display: 'flex', alignItems: 'center', @@ -60,11 +74,21 @@ const useStyles = makeStyles(theme => ({ marginLeft: theme.spacing(2.5), marginRight: theme.spacing(2.5), }, + errorBoxCompact: { + maxWidth: '100%', + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2), + }, dialogActions: { justifyContent: 'left', padding: theme.spacing(2.5), gap: theme.spacing(1), }, + dialogActionsCompact: { + justifyContent: 'left', + padding: `${theme.spacing(1.5)}px !important`, + gap: theme.spacing(1), + }, deleteButton: { textTransform: 'none', borderRadius: 999, @@ -81,12 +105,14 @@ export const DeleteNotebookModal = ({ onDeleted, sessionId, name, + isCompact = false, }: { isOpen: boolean; onClose: () => void; onDeleted: () => void; sessionId: string; name: string; + isCompact?: boolean; }) => { const classes = useStyles(); const { t } = useTranslation(); @@ -103,6 +129,8 @@ export const DeleteNotebookModal = ({ } }; + const scopedProps = getScopedDialogProps(isCompact); + return ( - + {t('notebooks.delete.title', { name } as any)} @@ -123,7 +155,7 @@ export const DeleteNotebookModal = ({ aria-label="close" onClick={onClose} title={t('common.close')} - size="large" + size={isCompact ? 'small' : 'large'} className={classes.closeButton} > @@ -132,16 +164,22 @@ export const DeleteNotebookModal = ({ {t('notebooks.delete.message')} {isError && ( - + {String(error)} )} - + ({ flexDirection: 'column', flex: 1, minHeight: 0, - height: '100%', + minWidth: 0, + width: '100%', + overflow: 'hidden', backgroundColor: 'var(--pf-t--global--background--color--primary--default)', }, drawerContainer: { flex: 1, minHeight: 0, + minWidth: 0, + '& .pf-v6-c-drawer__content, & .pf-v5-c-drawer__content': { + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }, '& .pf-v6-c-drawer__panel, & .pf-v5-c-drawer__panel': { backgroundColor: 'var(--pf-t--global--background--color--floating--default) !important', @@ -113,7 +128,8 @@ const useStyles = makeStyles(theme => ({ mainArea: { display: 'flex', flexDirection: 'row', - height: '100%', + flex: 1, + minHeight: 0, minWidth: 0, }, topBar: { @@ -131,10 +147,15 @@ const useStyles = makeStyles(theme => ({ flexDirection: 'column', flex: 1, minHeight: 0, + minWidth: 0, }, drawerContentBody: { backgroundColor: 'var(--pf-t--global--background--color--primary--default)', - height: '100%', + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + minWidth: 0, }, contentColumn: { display: 'flex', @@ -197,6 +218,7 @@ const useStyles = makeStyles(theme => ({ display: 'flex', flexDirection: 'column', minHeight: 0, + minWidth: 0, backgroundColor: 'var(--pf-t--global--background--color--floating--default)', }, @@ -271,6 +293,11 @@ const useStyles = makeStyles(theme => ({ }, })); +export type NotebookViewHandle = { + openUploadModal: () => void; + toggleSidebar: () => void; +}; + type NotebookViewProps = { sessionId: string; notebookName?: string; @@ -283,540 +310,585 @@ type NotebookViewProps = { profileLoading: boolean; topicRestrictionEnabled: boolean; onClose: () => void; + isCompact?: boolean; + onUploadsInProgressChange?: (inProgress: boolean) => void; + onSidebarCollapsedChange?: (collapsed: boolean) => void; + onUploadModalOpenChange?: (open: boolean) => void; }; -export const NotebookView = ({ - sessionId, - notebookName = UNTITLED_NOTEBOOK_NAME, - documents = [], - isDocumentsFetching = false, - metadata, - topicSummary, - userName, - avatar, - profileLoading, - topicRestrictionEnabled, - onClose, -}: NotebookViewProps) => { - const classes = useStyles(); - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const configApi = useApi(configApiRef); - const notebooksApi = useApi(notebooksApiRef); - const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); - - // Use notebook-specific model from config instead of chat's selected model - const notebookModel = - configApi.getOptionalString( - 'intelligent-assistant.notebooks.queryDefaults.model', - ) || ''; - - const [conversationId, setConversationId] = useState( - metadata?.conversation_id ?? TEMP_CONVERSATION_ID, - ); - const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); - const [announcement, setAnnouncement] = useState( - undefined, - ); - const [deletingDocumentIds, setDeletingDocumentIds] = useState>( - new Set(), - ); - const [deleteDocumentTarget, setDeleteDocumentTarget] = useState<{ - id: string; - name: string; - } | null>(null); - - const handleDeleteDocument = useCallback((documentId: string) => { - setDeleteDocumentTarget({ id: documentId, name: documentId }); - }, []); - - const onComplete = useCallback( - (message: string) => { - setIsSendButtonDisabled(false); - setAnnouncement(`Message from Bot: ${message}`); - queryClient.invalidateQueries({ - queryKey: ['conversationMessages', conversationId], - }); - }, - [queryClient, conversationId], - ); - - const onStart = useCallback((conv_id: string) => { - setConversationId(conv_id); - }, []); - - const createMessageAdapter = useCallback( - async (vars: CreateMessageVariables) => { - return notebookCreateMessage({ - prompt: vars.prompt, - sessionId, - }); - }, - [notebookCreateMessage, sessionId], - ); - - const { conversationMessages, handleInputPrompt, scrollToBottomRef } = - useConversationMessages( - conversationId, +export const NotebookView = forwardRef( + ( + { + sessionId, + notebookName = UNTITLED_NOTEBOOK_NAME, + documents = [], + isDocumentsFetching = false, + metadata, + topicSummary, userName, - notebookModel, - '', avatar, - onComplete, - onStart, - createMessageAdapter, + profileLoading, + topicRestrictionEnabled, + onClose, + isCompact = false, + onUploadsInProgressChange, + onSidebarCollapsedChange, + onUploadModalOpenChange, + }, + ref, + ) => { + const classes = useStyles(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const configApi = useApi(configApiRef); + const notebooksApi = useApi(notebooksApiRef); + const { mutateAsync: notebookCreateMessage } = useCreateNotebookMessage(); + + // Use notebook-specific model from config instead of chat's selected model + const notebookModel = + configApi.getOptionalString( + 'intelligent-assistant.notebooks.queryDefaults.model', + ) || ''; + + const [conversationId, setConversationId] = useState( + metadata?.conversation_id ?? TEMP_CONVERSATION_ID, + ); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); + const [announcement, setAnnouncement] = useState( + undefined, + ); + const [deletingDocumentIds, setDeletingDocumentIds] = useState>( + new Set(), + ); + const [deleteDocumentTarget, setDeleteDocumentTarget] = useState<{ + id: string; + name: string; + } | null>(null); + + const handleDeleteDocument = useCallback((documentId: string) => { + setDeleteDocumentTarget({ id: documentId, name: documentId }); + }, []); + + const onComplete = useCallback( + (message: string) => { + setIsSendButtonDisabled(false); + setAnnouncement(`Message from Bot: ${message}`); + queryClient.invalidateQueries({ + queryKey: ['conversationMessages', conversationId], + }); + }, + [queryClient, conversationId], ); - const [messages, setMessages] = - useState(conversationMessages); + const onStart = useCallback((conv_id: string) => { + setConversationId(conv_id); + }, []); - useEffect(() => { - setMessages(conversationMessages); - }, [conversationMessages]); + const createMessageAdapter = useCallback( + async (vars: CreateMessageVariables) => { + return notebookCreateMessage({ + prompt: vars.prompt, + sessionId, + }); + }, + [notebookCreateMessage, sessionId], + ); - const sendMessage = useCallback( - (message: string | number) => { - setAnnouncement( - t('conversation.announcement.userMessage' as any, { - prompt: message.toString(), - }), + const { conversationMessages, handleInputPrompt, scrollToBottomRef } = + useConversationMessages( + conversationId, + userName, + notebookModel, + '', + avatar, + onComplete, + onStart, + createMessageAdapter, ); - handleInputPrompt(message.toString(), []); - setIsSendButtonDisabled(true); - }, - [handleInputPrompt, t], - ); - - const notebookPrompts = useNotebookWelcomePrompts(); - const welcomePrompts = notebookPrompts.map(title => ({ - title, - onClick: () => sendMessage(title), - })); - - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); - const [uploadingFileNames, setUploadingFileNames] = useState([]); - const [pendingUploads, setPendingUploads] = useState([]); - const [toastAlerts, setToastAlerts] = useState[]>([]); - const processedIds = useRef>(new Set()); - const [completedFileNames, setCompletedFileNames] = useState>( - new Set(), - ); - const [filesToOverwrite, setFilesToOverwrite] = useState([]); - const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); - const [filesToAddToModal, setFilesToAddToModal] = useState([]); - - const confirmDeleteDocument = useCallback(async () => { - if (!deleteDocumentTarget) return; - const { id: documentId, name: documentName } = deleteDocumentTarget; - setDeleteDocumentTarget(null); - setDeletingDocumentIds(prev => new Set(prev).add(documentId)); - try { - await notebooksApi.deleteDocument(sessionId, documentId); - queryClient.invalidateQueries({ - queryKey: ['notebooks', 'documents', sessionId], + + const [messages, setMessages] = + useState(conversationMessages); + + useEffect(() => { + setMessages(conversationMessages); + }, [conversationMessages]); + + const sendMessage = useCallback( + (message: string | number) => { + setAnnouncement( + t('conversation.announcement.userMessage' as any, { + prompt: message.toString(), + }), + ); + handleInputPrompt(message.toString(), []); + setIsSendButtonDisabled(true); + }, + [handleInputPrompt, t], + ); + + const notebookPrompts = useNotebookWelcomePrompts(); + const welcomePrompts = notebookPrompts.map(title => ({ + title, + onClick: () => sendMessage(title), + })); + + const [sidebarCollapsed, setSidebarCollapsed] = useState(isCompact); + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + + useImperativeHandle(ref, () => ({ + openUploadModal: () => setIsUploadModalOpen(true), + toggleSidebar: () => setSidebarCollapsed(prev => !prev), + })); + const [uploadingFileNames, setUploadingFileNames] = useState([]); + const [pendingUploads, setPendingUploads] = useState([]); + const [toastAlerts, setToastAlerts] = useState[]>([]); + const processedIds = useRef>(new Set()); + const [completedFileNames, setCompletedFileNames] = useState>( + new Set(), + ); + const [filesToOverwrite, setFilesToOverwrite] = useState([]); + const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); + const [filesToAddToModal, setFilesToAddToModal] = useState([]); + + const confirmDeleteDocument = useCallback(async () => { + if (!deleteDocumentTarget) return; + const { id: documentId, name: documentName } = deleteDocumentTarget; + setDeleteDocumentTarget(null); + setDeletingDocumentIds(prev => new Set(prev).add(documentId)); + try { + await notebooksApi.deleteDocument(sessionId, documentId); + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', sessionId], + }); + setToastAlerts(prev => [ + { + key: Date.now() + documentId, + title: (t as Function)('notebook.document.delete.success', { + documentName, + }) as string, + variant: 'success', + }, + ...prev, + ]); + } finally { + setDeletingDocumentIds(prev => { + const next = new Set(prev); + next.delete(documentId); + return next; + }); + } + }, [deleteDocumentTarget, notebooksApi, sessionId, queryClient, t]); + + const handleOpenUploadModal = () => setIsUploadModalOpen(true); + const handleCloseUploadModal = () => setIsUploadModalOpen(false); + + const handleFilesUploading = (files: File[]) => { + setUploadingFileNames(prev => { + const newNames = files + .map(f => f.name) + .filter(name => !prev.includes(name)); + return [...prev, ...newNames]; }); + }; + + const handleUploadStarted = (info: { + fileName: string; + documentId: string; + }) => { + processedIds.current.delete(info.documentId); + setPendingUploads(prev => [ + ...prev, + { fileName: info.fileName, documentId: info.documentId }, + ]); + }; + + const handleUploadFailed = (fileName: string) => { + setUploadingFileNames(prev => prev.filter(n => n !== fileName)); setToastAlerts(prev => [ { - key: Date.now() + documentId, - title: (t as Function)('notebook.document.delete.success', { - documentName, + key: Date.now() + fileName, + title: (t as Function)('notebook.upload.failed', { + fileName, }) as string, - variant: 'success', + variant: 'danger', }, ...prev, ]); - } finally { - setDeletingDocumentIds(prev => { - const next = new Set(prev); - next.delete(documentId); - return next; - }); - } - }, [deleteDocumentTarget, notebooksApi, sessionId, queryClient, t]); - - const handleOpenUploadModal = () => setIsUploadModalOpen(true); - const handleCloseUploadModal = () => setIsUploadModalOpen(false); - - const handleFilesUploading = (files: File[]) => { - setUploadingFileNames(prev => { - const newNames = files - .map(f => f.name) - .filter(name => !prev.includes(name)); - return [...prev, ...newNames]; - }); - }; - - const handleUploadStarted = (info: { - fileName: string; - documentId: string; - }) => { - processedIds.current.delete(info.documentId); - setPendingUploads(prev => [ - ...prev, - { fileName: info.fileName, documentId: info.documentId }, - ]); - }; - - const handleUploadFailed = (fileName: string) => { - setUploadingFileNames(prev => prev.filter(n => n !== fileName)); - setToastAlerts(prev => [ - { - key: Date.now() + fileName, - title: (t as Function)('notebook.upload.failed', { - fileName, - }) as string, - variant: 'danger', - }, - ...prev, - ]); - }; - - const handleDuplicatesFound = (files: File[]) => { - setFilesToOverwrite(files); - setIsOverwriteModalOpen(true); - }; - - const handleOverwriteConfirm = () => { - const files = filesToOverwrite; - setIsOverwriteModalOpen(false); - setFilesToOverwrite([]); - - if (files.length === 0) return; - - setFilesToAddToModal(files); - }; - - const handleFilesAddedToModal = () => { - setFilesToAddToModal([]); - }; - - const handleOverwriteCancel = () => { - setIsOverwriteModalOpen(false); - setFilesToOverwrite([]); - }; - - const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); - - useEffect(() => { - const completedOrFailed = pollingResults.filter( - r => - (r.status === 'completed' || - r.status === 'failed' || - r.status === 'cancelled') && - !processedIds.current.has(r.documentId), - ); + }; - if (completedOrFailed.length === 0) return; + const handleDuplicatesFound = (files: File[]) => { + setFilesToOverwrite(files); + setIsOverwriteModalOpen(true); + }; - const idsToRemove = new Set(); - const namesToRemove = new Set(); - const newAlerts: Partial[] = []; + const handleOverwriteConfirm = () => { + const files = filesToOverwrite; + setIsOverwriteModalOpen(false); + setFilesToOverwrite([]); - const newCompletedNames = new Set(); + if (files.length === 0) return; + + setFilesToAddToModal(files); + }; + + const handleFilesAddedToModal = () => { + setFilesToAddToModal([]); + }; + + const handleOverwriteCancel = () => { + setIsOverwriteModalOpen(false); + setFilesToOverwrite([]); + }; + + const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); + + useEffect(() => { + const completedOrFailed = pollingResults.filter( + r => + (r.status === 'completed' || + r.status === 'failed' || + r.status === 'cancelled') && + !processedIds.current.has(r.documentId), + ); - for (const result of completedOrFailed) { - processedIds.current.add(result.documentId); - idsToRemove.add(result.documentId); - namesToRemove.add(result.fileName); - if (result.status === 'completed') { - newCompletedNames.add(result.fileName); + if (completedOrFailed.length === 0) return; + + const idsToRemove = new Set(); + const namesToRemove = new Set(); + const newAlerts: Partial[] = []; + + const newCompletedNames = new Set(); + + for (const result of completedOrFailed) { + processedIds.current.add(result.documentId); + idsToRemove.add(result.documentId); + namesToRemove.add(result.fileName); + if (result.status === 'completed') { + newCompletedNames.add(result.fileName); + } + + if (result.status !== 'completed') { + const errorDetail = result.error ? ` ${result.error}` : ''; + newAlerts.push({ + key: Date.now() + result.documentId, + title: `${ + (t as Function)('notebook.upload.failed', { + fileName: result.fileName, + }) as string + }${errorDetail}`, + variant: 'danger', + }); + } } - if (result.status !== 'completed') { - const errorDetail = result.error ? ` ${result.error}` : ''; - newAlerts.push({ - key: Date.now() + result.documentId, - title: `${ - (t as Function)('notebook.upload.failed', { - fileName: result.fileName, - }) as string - }${errorDetail}`, - variant: 'danger', + setPendingUploads(prev => + prev.filter(u => !idsToRemove.has(u.documentId)), + ); + setUploadingFileNames(prev => + prev.filter(name => !namesToRemove.has(name)), + ); + if (newCompletedNames.size > 0) { + setCompletedFileNames(prev => new Set([...prev, ...newCompletedNames])); + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'documents', sessionId], }); } - } - - setPendingUploads(prev => prev.filter(u => !idsToRemove.has(u.documentId))); - setUploadingFileNames(prev => - prev.filter(name => !namesToRemove.has(name)), + setToastAlerts(prev => [...newAlerts, ...prev]); + }, [pollingResults, t, queryClient, sessionId]); + + const handleRemoveToastAlert = (key: React.Key) => { + setToastAlerts(prev => prev.filter(a => a.key !== key)); + }; + + const totalDocumentCount = documents.length + uploadingFileNames.length; + const hasUploadsInProgress = + pendingUploads.length > 0 || isDocumentsFetching; + const hasNoDocuments = documents.length === 0; + const isAddDisabled = + totalDocumentCount >= NOTEBOOK_MAX_FILES || hasUploadsInProgress; + + useEffect(() => { + onUploadsInProgressChange?.(hasUploadsInProgress); + }, [hasUploadsInProgress, onUploadsInProgressChange]); + + useEffect(() => { + onSidebarCollapsedChange?.(sidebarCollapsed); + }, [sidebarCollapsed, onSidebarCollapsedChange]); + + useEffect(() => { + onUploadModalOpenChange?.(isUploadModalOpen); + }, [isUploadModalOpen, onUploadModalOpenChange]); + + const panelContent = ( + + setSidebarCollapsed(prev => !prev)} + onAddDocument={handleOpenUploadModal} + onDeleteDocument={handleDeleteDocument} + /> + ); - if (newCompletedNames.size > 0) { - setCompletedFileNames(prev => new Set([...prev, ...newCompletedNames])); - queryClient.invalidateQueries({ - queryKey: ['notebooks', 'documents', sessionId], - }); - } - setToastAlerts(prev => [...newAlerts, ...prev]); - }, [pollingResults, t, queryClient, sessionId]); - - const handleRemoveToastAlert = (key: React.Key) => { - setToastAlerts(prev => prev.filter(a => a.key !== key)); - }; - - const totalDocumentCount = documents.length + uploadingFileNames.length; - const hasUploadsInProgress = pendingUploads.length > 0 || isDocumentsFetching; - const hasNoDocuments = documents.length === 0; - const isAddDisabled = - totalDocumentCount >= NOTEBOOK_MAX_FILES || hasUploadsInProgress; - - const panelContent = ( - - setSidebarCollapsed(prev => !prev)} - onAddDocument={handleOpenUploadModal} - onDeleteDocument={handleDeleteDocument} - /> - - ); - - const renderNotebookDisclaimerAlert = () => ( - - - - {t('disclaimer.withoutValidation')} - + + const renderNotebookDisclaimerAlert = () => ( + + + + {t('disclaimer.withoutValidation')} + + - - ); + ); - const renderMainContent = () => { - if (hasNoDocuments && messages.length === 0) { - return ( - - 0} - /> - - ); - } - if (messages.length > 0) { - return ( - - - - ); - } - return ( - - - {renderNotebookDisclaimerAlert()} - - - {notebookName} + const renderMainContent = () => { + if (hasNoDocuments && messages.length === 0) { + return ( + + 0} + /> - {topicSummary && ( - - {topicSummary} + ); + } + if (messages.length > 0) { + return ( + + + + ); + } + return ( + + + {renderNotebookDisclaimerAlert()} + + + {notebookName} + {topicSummary && ( + + {topicSummary} + + )} + + {welcomePrompts.length > 0 && ( + + {welcomePrompts.map(prompt => ( + + {prompt.title} + + ))} + )} - {welcomePrompts.length > 0 && ( - - {welcomePrompts.map(prompt => ( - - {prompt.title} - + ); + }; + + return ( + + {toastAlerts.length > 0 && ( + + {toastAlerts.map(({ key, title, variant }) => ( + handleRemoveToastAlert(key as React.Key)} + actionClose={ + handleRemoveToastAlert(key as React.Key)} + /> + } + /> ))} - + )} - - ); - }; - - return ( - - {toastAlerts.length > 0 && ( - - {toastAlerts.map(({ key, title, variant }) => ( - handleRemoveToastAlert(key as React.Key)} - actionClose={ - handleRemoveToastAlert(key as React.Key)} - /> - } - /> - ))} - - )} - - - - - {sidebarCollapsed && ( - - - setSidebarCollapsed(false)} - aria-label={t('notebook.view.sidebar.expand')} - size="sm" + + + + {sidebarCollapsed && !isCompact && ( + + - - - - { - if (hasUploadsInProgress) - return t('notebook.view.documents.uploadsInProgress'); - if (isAddDisabled) - return t('notebook.view.documents.maxReached'); - return t('notebook.view.documents.add'); - })()} - position="right" - > - setSidebarCollapsed(false)} + aria-label={t('notebook.view.sidebar.expand')} + size="sm" > - + - - - - )} - - - - } - iconPosition="end" - > - {t('notebook.view.close')} - - - - {renderMainContent()} - - {hasNoDocuments && - messages.length === 0 && - renderNotebookDisclaimerAlert()} - - - {hasNoDocuments ? ( + { + if (hasUploadsInProgress) + return t('notebook.view.documents.uploadsInProgress'); + if (isAddDisabled) + return t('notebook.view.documents.maxReached'); + return t('notebook.view.documents.add'); + })()} + position="right" > - - - + + + + + - ) : ( - + + )} + + + {!isCompact && ( + + } + iconPosition="end" + > + {t('notebook.view.close')} + + )} - - + + + {renderMainContent()} + + + {hasNoDocuments && + messages.length === 0 && + renderNotebookDisclaimerAlert()} + + + {hasNoDocuments ? ( + + + + + + ) : ( + + )} + + + - - - - - - d.title)} - hasUploadsInProgress={hasUploadsInProgress} - onFilesUploading={handleFilesUploading} - onUploadStarted={handleUploadStarted} - onUploadFailed={handleUploadFailed} - onDuplicatesFound={handleDuplicatesFound} - filesToAdd={filesToAddToModal} - onFilesAdded={handleFilesAddedToModal} - /> - - f.name)} - /> - - setDeleteDocumentTarget(null)} - onConfirm={confirmDeleteDocument} - documentName={deleteDocumentTarget?.name ?? ''} - /> - - ); -}; + + + + + d.title)} + hasUploadsInProgress={hasUploadsInProgress} + onFilesUploading={handleFilesUploading} + onUploadStarted={handleUploadStarted} + onUploadFailed={handleUploadFailed} + onDuplicatesFound={handleDuplicatesFound} + filesToAdd={filesToAddToModal} + onFilesAdded={handleFilesAddedToModal} + isCompact={isCompact} + /> + + 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/OverwriteConfirmModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/OverwriteConfirmModal.tsx index 61327087ef8..ebce6cde55d 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/OverwriteConfirmModal.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/OverwriteConfirmModal.tsx @@ -26,6 +26,7 @@ import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; import { useTranslation } from '../../hooks/useTranslation'; +import { getScopedDialogProps } from '../../utils/scoped-dialog-utils'; import { FileTypeIcon } from './FileTypeIcon'; const useStyles = makeStyles(theme => ({ @@ -97,6 +98,7 @@ type OverwriteConfirmModalProps = { onClose: () => void; onConfirm: () => void; fileNames: string[]; + isCompact?: boolean; }; export const OverwriteConfirmModal = ({ @@ -104,6 +106,7 @@ export const OverwriteConfirmModal = ({ onClose, onConfirm, fileNames, + isCompact = false, }: OverwriteConfirmModalProps) => { const classes = useStyles(); const { t } = useTranslation(); @@ -116,6 +119,7 @@ export const OverwriteConfirmModal = ({ PaperProps={{ className: classes.dialogPaper, }} + {...getScopedDialogProps(isCompact)} > diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/RenameNotebookModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/RenameNotebookModal.tsx index 0bc79b88526..181c17c2585 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/RenameNotebookModal.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/RenameNotebookModal.tsx @@ -31,15 +31,24 @@ import Typography from '@mui/material/Typography'; import { useRenameNotebook } from '../../hooks/notebooks/useRenameNotebook'; import { useTranslation } from '../../hooks/useTranslation'; +import { getScopedDialogProps } from '../../utils/scoped-dialog-utils'; const useStyles = makeStyles(theme => ({ dialogPaper: { borderRadius: 16, }, + dialogPaperCompact: { + borderRadius: 12, + padding: theme.spacing(1), + }, dialogTitle: { padding: '16px 20px', fontStyle: 'inherit', }, + dialogTitleCompact: { + padding: '12px 16px', + fontStyle: 'inherit', + }, titleRow: { display: 'flex', alignItems: 'center', @@ -58,9 +67,17 @@ const useStyles = makeStyles(theme => ({ paddingTop: 0, paddingLeft: theme.spacing(2.5), }, + dialogContentCompact: { + paddingTop: 0, + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, description: { marginBottom: theme.spacing(3), }, + descriptionCompact: { + marginBottom: theme.spacing(2), + }, textField: { marginTop: 0, }, @@ -69,11 +86,21 @@ const useStyles = makeStyles(theme => ({ marginLeft: theme.spacing(2.5), marginRight: theme.spacing(2.5), }, + errorBoxCompact: { + maxWidth: '100%', + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2), + }, dialogActions: { justifyContent: 'left', padding: theme.spacing(2.5), gap: theme.spacing(1), }, + dialogActionsCompact: { + justifyContent: 'left', + padding: theme.spacing(1.5), + gap: theme.spacing(1), + }, submitButton: { textTransform: 'none', borderRadius: 999, @@ -93,11 +120,13 @@ export const RenameNotebookModal = ({ onClose, sessionId, currentName, + isCompact = false, }: { isOpen: boolean; onClose: () => void; sessionId: string; currentName: string; + isCompact?: boolean; }) => { const classes = useStyles(); const { t } = useTranslation(); @@ -122,6 +151,8 @@ export const RenameNotebookModal = ({ } }; + const scopedProps = getScopedDialogProps(isCompact); + return ( - + {t('notebooks.rename.title').replace('{{name}}', currentName)} @@ -142,7 +177,7 @@ export const RenameNotebookModal = ({ aria-label="close" onClick={onClose} title={t('common.close')} - size="large" + size={isCompact ? 'small' : 'large'} className={classes.closeButton} > @@ -151,12 +186,16 @@ export const RenameNotebookModal = ({ {t('notebooks.rename.description')} @@ -175,11 +214,15 @@ export const RenameNotebookModal = ({ /> {isError && ( - + {String(error)} )} - + ( +export const SidebarCollapseIcon = ({ className, size = 24 }: IconProps) => ( ); -export const SidebarExpandIcon = ({ className }: IconProps) => ( +export const SidebarExpandIcon = ({ className, size = 24 }: IconProps) => ( 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..61f261c1812 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 @@ -72,7 +72,6 @@ export const UploadResourceScreen = ({ }: UploadResourceScreenProps) => { const classes = useStyles(); const { t } = useTranslation(); - return ( diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx index 64b60f9d134..24dec0802b7 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedDrawerContext.test.tsx @@ -37,6 +37,8 @@ describe('useLightspeedDrawerContext', () => { consumePendingOverlayThreadHandoff: jest.fn(() => false), shellViewTab: 0, setShellViewTab: jest.fn(), + activeNotebookId: undefined, + setActiveNotebookId: jest.fn(), }; it('should return context value when used within provider', () => { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedProviderState.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedProviderState.test.tsx index 065afb8762b..b2a56447390 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedProviderState.test.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/__tests__/useLightspeedProviderState.test.tsx @@ -386,7 +386,7 @@ describe('useLightspeedProviderState', () => { }); }); - it('resets shellViewTab to Chat when leaving embedded for overlay while on Notebooks', async () => { + it('preserves shellViewTab when leaving embedded for overlay while on Notebooks', async () => { renderWithRouter(['/catalog']); screen.getByTestId('set-shell-notebooks-tab').click(); @@ -406,7 +406,7 @@ describe('useLightspeedProviderState', () => { await waitFor(() => { expect(screen.getByTestId('pathname')).toHaveTextContent('/catalog'); - expect(screen.getByTestId('shell-view-tab')).toHaveTextContent('0'); + expect(screen.getByTestId('shell-view-tab')).toHaveTextContent('1'); }); }); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts index 13555d3b594..e8929fe9086 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts @@ -67,6 +67,9 @@ export function useLightspeedProviderState(): { FileContent[] >([]); const [shellViewTab, setShellViewTabState] = useState(0); + const [activeNotebookId, setActiveNotebookIdState] = useState< + string | undefined + >(undefined); const shellViewTabRef = useRef(shellViewTab); shellViewTabRef.current = shellViewTab; const setShellViewTab = useCallback((tab: number) => { @@ -74,6 +77,9 @@ export function useLightspeedProviderState(): { shellViewTabRef.current = next; setShellViewTabState(next); }, []); + const setActiveNotebookId = useCallback((id: string | undefined) => { + setActiveNotebookIdState(id); + }, []); const openedViaFABRef = useRef(false); const dockedAfterLeavingFullscreenRef = useRef(false); /** True while navigating off /lightspeed after user chose overlay/docked (URL can lag persisted mode). */ @@ -311,9 +317,6 @@ export function useLightspeedProviderState(): { } setIsOpen(true); } else { - // Notebooks exist only in fullscreen; leaving embedded for overlay/docked - // must not keep shellViewTab on Notebooks (next fullscreen open should be Chat). - setShellViewTab(0); if (isLightspeedRoute) { leavingLightspeedForNonEmbeddedShellRef.current = true; pendingOverlayThreadHandoffRef.current = true; @@ -329,7 +332,6 @@ export function useLightspeedProviderState(): { leaveLightspeedRouteForShellDisplayMode, navigate, setPersistedDisplayMode, - setShellViewTab, syncShellDrawerForMode, ], ); @@ -356,6 +358,8 @@ export function useLightspeedProviderState(): { consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, + activeNotebookId, + setActiveNotebookId, }), [ isOpen, @@ -372,6 +376,8 @@ export function useLightspeedProviderState(): { consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, + activeNotebookId, + setActiveNotebookId, ], ); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/scoped-dialog-utils.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/scoped-dialog-utils.ts new file mode 100644 index 00000000000..0f094825b2c --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/scoped-dialog-utils.ts @@ -0,0 +1,51 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DialogProps } from '@mui/material/Dialog'; + +export function getScopedDialogProps(isCompact: boolean): Partial { + if (!isCompact) return {}; + return { + disablePortal: true, + disableScrollLock: true, + fullWidth: true, + maxWidth: false, + sx: { + position: 'absolute', + inset: 0, + margin: 0, + // padding: 0, + '& [class*="Backdrop-root"]': { + position: 'absolute', + }, + }, + PaperProps: { + sx: { + marginTop: '16px !important', + marginBottom: '16px !important', + marginLeft: '40px !important', + marginRight: '40px !important', + borderRadius: '12px !important', + width: 'calc(100% - 80px) !important', + maxWidth: 'calc(100% - 80px) !important', + maxHeight: 'calc(100% - 32px) !important', + overflowX: 'hidden', + overflowY: 'auto', + boxSizing: 'border-box', + }, + }, + }; +}