From e0dd1fab45be55e381fae91a11a6c83dbcab64c4 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 30 Jul 2026 07:13:44 +0530 Subject: [PATCH 01/23] feat(intelligent-assistant): implement docked and overlay display modes for Notebook Enable notebook functionality in docked and overlay (non-fullscreen) display modes. Previously, notebooks were only accessible in fullscreen/embedded mode. - Add activeNotebookId to drawer context for state management without routes - Show Chat/Notebooks tabs in overlay and docked modes - Manage notebook selection via state instead of URL navigation in compact modes - Add header actions (close, add document, toggle sidebar) for compact modes - Use single-column grid layout for notebook cards in narrow panels - Fix NotebookView flex chain for proper sizing within docked panels - Override PF Chatbot embedded CSS (min-height, overflow) to prevent content overflow in constrained containers - Make DrawerPanelContent take full width when in compact mode - Update tests for new context fields and tab visibility RHIDP-14656 Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 --- .../src/components/LightSpeedChat.tsx | 168 ++- .../components/LightspeedDrawerContext.tsx | 11 +- .../components/LightspeedDrawerProvider.tsx | 2 +- .../__tests__/LightspeedChat.test.tsx | 38 +- .../LightspeedDrawerProvider.test.tsx | 2 + .../LightspeedDrawerStateExposer.test.tsx | 2 + .../__tests__/LightspeedFAB.test.tsx | 2 + .../src/components/notebooks/NotebookView.tsx | 1042 +++++++++-------- .../notebooks/UploadResourceScreen.tsx | 1 - .../useLightspeedDrawerContext.test.tsx | 2 + .../src/hooks/useLightspeedProviderState.ts | 14 +- 11 files changed, 738 insertions(+), 546 deletions(-) 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..c9d5203b3fa 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,12 @@ 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 { SidebarExpandIcon } from './notebooks/SidebarCollapseIcon'; import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; @@ -146,8 +153,14 @@ const ConditionalWrapper = ({ const useStyles = makeStyles(theme => ({ body: { - // remove default margin and padding from common elements - // lists excluded for proper formatting + height: '100% !important', + minHeight: '0 !important', + overflow: 'hidden', + '& .pf-chatbot-container': { + minHeight: '0 !important', + display: 'flex', + flexDirection: 'column', + }, '& h1, & h2, & h3, & h4, & h5, & h6, & p, & li': { margin: 0, padding: 0, @@ -191,6 +204,11 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', }, }, + notebookHeaderActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, headerLogo: { width: 48, height: 48, @@ -280,6 +298,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 +692,8 @@ export const LightspeedChat = ({ consumePendingOverlayThreadHandoff, shellViewTab, setShellViewTab, + activeNotebookId, + setActiveNotebookId, } = useLightspeedDrawerContext(); const isFullscreenMode = displayMode === ChatbotDisplayMode.embedded; const location = useLocation(); @@ -676,9 +704,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,33 +734,44 @@ 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 [conversationId, setConversationId] = useState(''); @@ -756,7 +792,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 +830,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 +871,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 => @@ -1941,6 +1989,46 @@ export const LightspeedChat = ({ aria-label={t('aria.chatHistoryMenu')} /> )} + {!isFullscreenMode && showNotebooksPanel && activeNotebook && ( +
+ + + + + + + notebookViewRef.current?.openUploadModal()} + aria-label={t('notebook.view.documents.add')} + size="sm" + > + + + + + notebookViewRef.current?.toggleSidebar()} + aria-label={t('notebook.view.sidebar.expand')} + size="sm" + > + + + +
+ )} {isFullscreenMode && ( <> setIsMcpSettingsOpen(true)} /> - {isFullscreenMode &&
} - {isFullscreenMode && shouldShowTabs && ( + {(isFullscreenMode || shouldShowTabs) && ( +
+ )} + {shouldShowTabs && ( )} {showNotebooksPanel && @@ -2171,11 +2263,25 @@ export const LightspeedChat = ({ { - 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} 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/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index b07d0bd4688..35bb65fb3f8 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -14,7 +14,14 @@ * limitations under the License. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; @@ -72,12 +79,20 @@ const useStyles = makeStyles(theme => ({ 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,561 @@ type NotebookViewProps = { profileLoading: boolean; topicRestrictionEnabled: boolean; onClose: () => void; + isCompact?: boolean; }; -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, + }, + 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), - ); + }; + + 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([]); + }; - if (completedOrFailed.length === 0) return; + const handleOverwriteCancel = () => { + setIsOverwriteModalOpen(false); + setFilesToOverwrite([]); + }; - const idsToRemove = new Set(); - const namesToRemove = new Set(); - const newAlerts: Partial[] = []; + const pollingResults = useDocumentStatusPolling(sessionId, pendingUploads); - const newCompletedNames = new Set(); + 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; + + 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 => ( + + ))} +
)}
- {welcomePrompts.length > 0 && ( -
- {welcomePrompts.map(prompt => ( - + ); + }; + + 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 && ( -
- - - - { - if (hasUploadsInProgress) - return t('notebook.view.documents.uploadsInProgress'); - if (isAddDisabled) - return t('notebook.view.documents.maxReached'); - return t('notebook.view.documents.add'); - })()} - position="right" - > - - - -
- )} - -
-
- -
- -
{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 && ( +
+ +
)} - - + +
+ {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} + /> + + f.name)} + /> + + setDeleteDocumentTarget(null)} + onConfirm={confirmDeleteDocument} + documentName={deleteDocumentTarget?.name ?? ''} + /> +
+ ); + }, +); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/UploadResourceScreen.tsx index 2653946517a..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/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, ], ); From 3d4dec7a03a941d6859aa429fb86dd4f7f6e9cee Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 30 Jul 2026 07:27:29 +0530 Subject: [PATCH 02/23] fix(intelligent-assistant): update test to preserve notebook tab across display mode switch Notebooks now work in overlay/docked modes, so shellViewTab should remain on Notebooks (1) when switching from embedded to overlay instead of resetting to Chat (0). Also adds changeset for the feature. Co-Authored-By: Claude Opus 4.6 --- .../.changeset/notebook-overlay-docked-modes.md | 5 +++++ .../src/hooks/__tests__/useLightspeedProviderState.test.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md 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/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'); }); }); }); From e4d419a2009e3182b954b2708bfcc2ac23f626c4 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 30 Jul 2026 09:38:28 +0530 Subject: [PATCH 03/23] Delete, upload, rename modals render within the docked/overlay panel bounds Signed-off-by: rohitratannagar --- .../src/components/LightSpeedChat.tsx | 90 +++++++++++-------- .../components/notebooks/AddDocumentModal.tsx | 56 ++++++++++-- .../notebooks/DeleteDocumentModal.tsx | 43 +++++++-- .../notebooks/DeleteNotebookModal.tsx | 51 +++++++++-- .../src/components/notebooks/NotebookView.tsx | 8 +- .../notebooks/OverwriteConfirmModal.tsx | 4 + .../notebooks/RenameNotebookModal.tsx | 57 ++++++++++-- .../src/utils/scoped-dialog-utils.ts | 48 ++++++++++ 8 files changed, 294 insertions(+), 63 deletions(-) create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/scoped-dialog-utils.ts 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 c9d5203b3fa..bf5dbe4353a 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -1957,17 +1957,7 @@ 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} /> )} { - if (isFullscreenMode) { - navigate( - `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, - ); - } else { - setActiveNotebook(notebook); - setActiveNotebookId(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/notebooks/AddDocumentModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx index 7b85fbb5946..c25a4e9ab26 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,44 @@ const useStyles = makeStyles(theme => ({ borderRadius: 24, maxWidth: 578, }, + dialogPaperCompact: { + borderRadius: 12, + maxWidth: '100%', + padding: theme.spacing(0.5), + }, dialogTitle: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '24px 24px 16px', }, + dialogTitleCompact: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px 8px', + }, titleText: { fontWeight: 500, fontSize: '1.25rem', lineHeight: '1.625rem', letterSpacing: '-0.25px', }, + titleTextCompact: { + fontWeight: 500, + fontSize: '1rem', + lineHeight: '1.375rem', + letterSpacing: '-0.25px', + }, closeButton: { color: theme.palette.text.primary, }, dialogContent: { padding: '0 24px 24px', }, + dialogContentCompact: { + padding: '0 16px 16px', + }, errorAlert: { marginBottom: theme.spacing(2), }, @@ -100,6 +121,11 @@ const useStyles = makeStyles(theme => ({ justifyContent: 'flex-end', gap: theme.spacing(1), }, + dialogActionsCompact: { + padding: '12px 16px', + justifyContent: 'flex-end', + gap: theme.spacing(1), + }, addButton: { textTransform: 'none', }, @@ -120,6 +146,7 @@ type AddDocumentModalProps = { onDuplicatesFound?: (files: File[]) => void; filesToAdd?: File[]; onFilesAdded?: () => void; + isCompact?: boolean; }; export const AddDocumentModal = ({ @@ -134,13 +161,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 +251,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 +285,11 @@ export const AddDocumentModal = ({ - + {validationErrors.length > 0 && ( {validationErrors @@ -315,7 +355,11 @@ export const AddDocumentModal = ({ )} - + - ))} -
)}
- ); - }; - - return ( -
- {toastAlerts.length > 0 && ( - - {toastAlerts.map(({ key, title, variant }) => ( - handleRemoveToastAlert(key as React.Key)} - actionClose={ - handleRemoveToastAlert(key as React.Key)} - /> - } - /> + {welcomePrompts.length > 0 && ( +
+ {welcomePrompts.map(prompt => ( + ))} - +
)} - + ); + }; + + return ( +
+ {toastAlerts.length > 0 && ( + - - -
- {sidebarCollapsed && !isCompact && ( -
- ( + handleRemoveToastAlert(key as React.Key)} + actionClose={ + handleRemoveToastAlert(key as React.Key)} + /> + } + /> + ))} + + )} + + + +
+ {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" + > + - - { - 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 && ( +
+ - - + {t('notebook.view.close')} +
)} -
- {!isCompact && ( -
- -
- )} - -
- {renderMainContent()} -
+
{renderMainContent()}
- {hasNoDocuments && - messages.length === 0 && - renderNotebookDisclaimerAlert()} + {hasNoDocuments && + messages.length === 0 && + renderNotebookDisclaimerAlert()} - - {hasNoDocuments ? ( - -
- -
-
- ) : ( - - )} - + {hasNoDocuments ? ( + +
+ +
+
+ ) : ( + -
-
+ )} + +
- - - - - 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} - /> -
- ); - }, -); +
+ + + + + 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 ebce6cde55d..75e489629f3 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 @@ -34,24 +34,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: '1rem', + lineHeight: '1.375rem', + letterSpacing: '-0.25px', + }, closeButton: { color: theme.palette.text.primary, }, dialogContent: { padding: '0 24px 24px', }, + dialogContentCompact: { + padding: '0 16px 16px !important', + }, fileList: { margin: 0, padding: 0, @@ -66,6 +85,14 @@ const useStyles = makeStyles(theme => ({ '1px solid var(--pf-t--global--border--color--default, #c7c7c7)', cursor: 'pointer', }, + fileItemCompact: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: `${theme.spacing(1)}px 0`, + borderBottom: + '1px solid var(--pf-t--global--border--color--default, #c7c7c7)', + }, fileName: { flex: 1, minWidth: 0, @@ -75,22 +102,55 @@ const useStyles = makeStyles(theme => ({ fontSize: '0.875rem', lineHeight: '1.25rem', }, + fileNameCompact: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '0.8125rem', + lineHeight: '1.125rem', + }, dialogActions: { justifyContent: 'left', padding: theme.spacing(2.5), gap: theme.spacing(1), }, + dialogActionsCompact: { + justifyContent: 'flex-start', + padding: '12px 16px !important', + gap: theme.spacing(1), + }, overwriteButton: { textTransform: 'none', borderRadius: 999, }, + overwriteButtonCompact: { + textTransform: 'none', + borderRadius: 999, + fontSize: '0.8125rem', + padding: '4px 16px', + }, cancelButton: { textTransform: 'none', borderRadius: 999, }, + cancelButtonCompact: { + textTransform: 'none', + borderRadius: 999, + fontSize: '0.8125rem', + padding: '4px 16px', + }, warningAlert: { borderRadius: '6px', }, + warningAlertCompact: { + borderRadius: '6px', + fontSize: '0.8125rem', + '& .MuiAlert-icon': { + fontSize: '1.125rem', + }, + }, })); type OverwriteConfirmModalProps = { @@ -111,18 +171,26 @@ export const OverwriteConfirmModal = ({ const classes = useStyles(); const { t } = useTranslation(); + const scopedProps = getScopedDialogProps(isCompact); + return ( - - + + {t('notebook.overwrite.modal.title')} - + - - + + {t('notebook.overwrite.modal.description')}
    {fileNames.map(name => ( -
  • +
  • - {name} + + {name} +
  • ))}
- + 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 index 0f094825b2c..89f1201ead6 100644 --- 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 @@ -27,7 +27,6 @@ export function getScopedDialogProps(isCompact: boolean): Partial { position: 'absolute', inset: 0, margin: 0, - // padding: 0, '& [class*="Backdrop-root"]': { position: 'absolute', }, From 421274be364a174024c3ec2f873a14a08b9f3777 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Fri, 7 Aug 2026 04:22:11 +0530 Subject: [PATCH 08/23] chore: retrigger CI From 7c8ed4d805b971ad7bc4b634905e23c83847684a Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Mon, 10 Aug 2026 03:19:13 +0530 Subject: [PATCH 09/23] chore: retrigger CI From 92ed5f2c2af5e047bd9482755ed2e33edb9fe86b Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Tue, 11 Aug 2026 11:21:11 +0530 Subject: [PATCH 10/23] fix sonar issue Signed-off-by: rohitratannagar --- .../src/components/LightSpeedChat.tsx | 47 ++--------- .../src/components/ToastAlertGroup.tsx | 82 +++++++++++++++++++ .../__tests__/LightspeedChat.test.tsx | 8 +- .../components/notebooks/AddDocumentModal.tsx | 37 +-------- .../src/components/notebooks/NotebookView.tsx | 46 ++--------- .../notebooks/OverwriteConfirmModal.tsx | 37 +-------- .../notebooks/SidebarCollapseIcon.tsx | 3 +- .../notebooks/notebookDialogStyles.ts | 56 +++++++++++++ .../src/hooks/useLightspeedProviderState.ts | 9 +- 9 files changed, 161 insertions(+), 164 deletions(-) create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookDialogStyles.ts 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 b8e95e8d4fc..1c420a2a3d2 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -54,10 +54,6 @@ import { } from '@patternfly/chatbot'; import ChatbotConversationHistoryNav from '@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav'; import { - Alert, - AlertActionCloseButton, - AlertGroup, - AlertVariant, DropdownItem, Label, MenuToggle, @@ -132,6 +128,7 @@ import { NotebooksTab } from './notebooks/NotebooksTab'; import { NotebookView } from './notebooks/NotebookView'; 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`; @@ -457,18 +454,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, @@ -1907,32 +1892,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 && ( ({ + 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 d210386d104..e1ccf91d426 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 @@ -732,9 +732,9 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); - expect(screen.queryByRole('tab', { name: 'Chat' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( - screen.queryByRole('tab', { name: 'Notebooks' }), + screen.getByRole('tab', { name: 'Notebooks' }), ).toBeInTheDocument(); }); @@ -765,9 +765,9 @@ describe('LightspeedChat', () => { expect(screen.getByLabelText('Options')).toBeInTheDocument(); }); - expect(screen.queryByRole('tab', { name: 'Chat' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Chat' })).toBeInTheDocument(); expect( - screen.queryByRole('tab', { name: 'Notebooks' }), + screen.getByRole('tab', { name: 'Notebooks' }), ).toBeInTheDocument(); }); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/AddDocumentModal.tsx index 004473e2718..4d25c8f69bf 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 @@ -43,49 +43,16 @@ import { } from '../../utils/notebook-upload-utils'; import { getScopedDialogProps } from '../../utils/scoped-dialog-utils'; import { FileListItem } from './FileListItem'; +import { notebookDialogStyles } from './notebookDialogStyles'; const useStyles = makeStyles(theme => ({ - dialogPaper: { - 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', - }, + ...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', - }, - dialogContentCompact: { - padding: '0 16px 16px !important', - }, errorAlert: { marginBottom: theme.spacing(2), }, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 2408305543c..bfea20d0991 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -27,9 +27,6 @@ import { } from '@patternfly/chatbot'; import { Alert, - AlertActionCloseButton, - AlertGroup, - AlertVariant, Button, Drawer, DrawerContent, @@ -60,6 +57,7 @@ import { useTranslation } from '../../hooks/useTranslation'; import { NotebookSessionMetadata, SessionDocument } from '../../types'; import { ChatbotFootnoteWithIcon } from '../../utils/lightspeed-chatbox-utils'; import { LightspeedChatBox } from '../LightspeedChatBox'; +import { ToastAlertGroup } from '../ToastAlertGroup'; import { AddDocumentModal } from './AddDocumentModal'; import { DeleteDocumentModal } from './DeleteDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; @@ -186,18 +184,6 @@ const useStyles = makeStyles(theme => ({ maxWidth: 'unset', margin: '0 auto', }, - 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, - }, - }, welcomeContainer: { display: 'flex', flexDirection: 'column', @@ -695,32 +681,10 @@ export const NotebookView = ({ className={classes.root} style={isCompact ? { position: 'relative' as const } : undefined} > - {toastAlerts.length > 0 && ( - - {toastAlerts.map(({ key, title, variant }) => ( - handleRemoveToastAlert(key as React.Key)} - actionClose={ - handleRemoveToastAlert(key as React.Key)} - /> - } - /> - ))} - - )} + ({ - dialogPaper: { - 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', - }, + ...notebookDialogStyles(theme), titleTextCompact: { fontWeight: 600, fontSize: '1rem', lineHeight: '1.375rem', letterSpacing: '-0.25px', }, - closeButton: { - color: theme.palette.text.primary, - }, - dialogContent: { - padding: '0 24px 24px', - }, - dialogContentCompact: { - padding: '0 16px 16px !important', - }, fileList: { margin: 0, padding: 0, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/SidebarCollapseIcon.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/SidebarCollapseIcon.tsx index 4f55ba094be..64a83355e90 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/SidebarCollapseIcon.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/SidebarCollapseIcon.tsx @@ -47,7 +47,8 @@ export const SidebarExpandIcon = ({ className, size = 24 }: IconProps) => ( ); -type AddCircleFilledIconProps = IconProps & { +type AddCircleFilledIconProps = { + className?: string; disabled?: boolean; }; diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookDialogStyles.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookDialogStyles.ts new file mode 100644 index 00000000000..14d6278c175 --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookDialogStyles.ts @@ -0,0 +1,56 @@ +/* + * 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 { Theme } from '@material-ui/core/styles'; + +export const notebookDialogStyles = (theme: Theme) => + ({ + dialogPaper: { + 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', + }, + closeButton: { + color: theme.palette.text.primary, + }, + dialogContent: { + padding: '0 24px 24px', + }, + dialogContentCompact: { + padding: '0 16px 16px !important', + }, + }) as const; 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 e8929fe9086..c6f0cc1dab7 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts @@ -67,9 +67,9 @@ export function useLightspeedProviderState(): { FileContent[] >([]); const [shellViewTab, setShellViewTabState] = useState(0); - const [activeNotebookId, setActiveNotebookIdState] = useState< - string | undefined - >(undefined); + const [activeNotebookId, setActiveNotebookId] = useState( + undefined, + ); const shellViewTabRef = useRef(shellViewTab); shellViewTabRef.current = shellViewTab; const setShellViewTab = useCallback((tab: number) => { @@ -77,9 +77,6 @@ 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). */ From 8648ca32e5bc14078b97f4c11882672fa233f1db Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Tue, 11 Aug 2026 23:45:30 +0530 Subject: [PATCH 11/23] matching the chat header from the prototype Signed-off-by: rohitratannagar --- .../src/components/LightSpeedChat.tsx | 85 ++++++++++++++++--- 1 file changed, 72 insertions(+), 13 deletions(-) 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 1c420a2a3d2..1f1825e3820 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, @@ -58,6 +57,7 @@ import { Label, MenuToggle, MenuToggleElement, + Button as PfButton, Select, SelectList, SelectOption, @@ -126,6 +126,10 @@ import { DeleteNotebookModal } from './notebooks/DeleteNotebookModal'; import { NotebookHeaderActions } from './notebooks/NotebookHeaderActions'; import { NotebooksTab } from './notebooks/NotebooksTab'; import { NotebookView } from './notebooks/NotebookView'; +import { + SidebarCollapseIcon, + SidebarExpandIcon, +} from './notebooks/SidebarCollapseIcon'; import PermissionRequiredState from './PermissionRequiredState'; import { RenameConversationModal } from './RenameConversationModal'; import { ToastAlertGroup } from './ToastAlertGroup'; @@ -190,11 +194,28 @@ 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%', + }, + }, + headerNewChatButton: { + '&.pf-v6-c-button': { + color: 'var(--pf-t--global--color--brand--default)', + '&:hover': { + color: 'var(--pf-t--global--color--brand--hover)', + }, + '&:disabled, &.pf-m-disabled': { + color: 'var(--pf-t--global--text--color--disabled)', + }, }, }, notebookHeaderActions: { @@ -1935,13 +1956,47 @@ export const LightspeedChat = ({ {showChatPanel && !isFullscreenMode && ( - +
+ + + {isChatHistoryDrawerOpen ? ( + + ) : ( + + )} + + + {!isChatHistoryDrawerOpen && ( + + + + + + )} +
)} {!isFullscreenMode && showNotebooksPanel && activeNotebook && ( Date: Wed, 12 Aug 2026 15:10:00 +0530 Subject: [PATCH 12/23] feat: use outlined AddCircleOIcon and auto-close sidebar on chat select Replace PlusCircleIcon with AddCircleOIcon (outlined) across notebook components for visual consistency. Close chat history sidebar automatically when selecting a conversation in compact mode. Assisted-by: claude-opus-4 Co-authored-by: Cursor --- .../src/components/LightSpeedChat.tsx | 1 + .../src/components/notebooks/DocumentSidebar.tsx | 6 +++--- .../components/notebooks/NotebookHeaderActions.tsx | 11 ++++++++--- .../src/components/notebooks/NotebooksTab.tsx | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) 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 1f1825e3820..03e2c9a0907 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -1415,6 +1415,7 @@ export const LightspeedChat = ({ (_: MouseEvent | undefined, selectedItem: string | number | undefined) => { if (!isFullscreenMode) { setIsMcpSettingsOpen(false); + setIsChatHistoryDrawerOpen(false); } setNewChatCreated(false); const newConvId = String(selectedItem); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx index b9b0ab6cbb5..93ea12da507 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DocumentSidebar.tsx @@ -27,7 +27,7 @@ import { TextInput, Tooltip, } from '@patternfly/react-core'; -import { EllipsisVIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { AddCircleOIcon, EllipsisVIcon } from '@patternfly/react-icons'; import { NOTEBOOK_MAX_FILES } from '../../const'; import { useInlineEdit } from '../../hooks/notebooks/useInlineEdit'; @@ -252,7 +252,7 @@ export const DocumentSidebar = ({ + + ); return hasNoDocuments ? (
) : ( Date: Mon, 17 Aug 2026 13:54:14 +0530 Subject: [PATCH 15/23] fix(intelligent-assistant): fix prettier formatting in OverwriteConfirmModal Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Opus 4.6 --- .../lightspeed.notebooks-compact.test.ts | 15 ++++++--------- .../notebooks/OverwriteConfirmModal.tsx | 5 ++++- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts index 94639784784..01f66003a18 100644 --- a/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts +++ b/workspaces/intelligent-assistant/e2e-tests/lightspeed.notebooks-compact.test.ts @@ -176,14 +176,8 @@ for (const mode of ['Overlay', 'Dock to window'] as const) { ); await expect(dialog).toBeVisible({ timeout: 10_000 }); - const browseButton = dialog.locator('button', { - hasText: translations['notebook.upload.modal.browseButton'], - }); - const [fileChooser] = await Promise.all([ - sharedPage.waitForEvent('filechooser'), - browseButton.click(), - ]); - await fileChooser.setFiles([absolutePath]); + const fileInput = dialog.locator('input[type="file"]'); + await fileInput.setInputFiles([absolutePath]); const stagedCaption = translations['notebook.upload.modal.selectedFiles'] .replace('{{count}}', '1') @@ -227,7 +221,10 @@ for (const mode of ['Overlay', 'Dock to window'] as const) { .click(); await expect(notebooks.myNotebooksHeading()).toBeVisible(); - await expect(notebooks.newestUntitledNotebookCard()).toBeVisible(); + // Empty notebooks (no uploaded documents) are auto-deleted on close + await expect( + notebooks.createNotebookFromEmptyStateButton(), + ).toBeVisible(); }); test('display mode switch preserves notebooks tab', async () => { 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 c49da87b3e6..2518de9ca2b 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 @@ -249,7 +249,10 @@ export const OverwriteConfirmModal = ({
    {allFiles.map(file => ( -
  • +
  • {file.name} {duplicateSet.has(file.name) && ( From 96eb5882cc59f80d6f7820241b63c8dffb1f438a Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Wed, 19 Aug 2026 15:23:16 +0530 Subject: [PATCH 16/23] fix(intelligent-assistant): prevent notebook auto-delete on display mode switch When switching display modes (e.g., overlay to fullscreen), NotebookView unmounts and its cleanup effect was auto-deleting untitled empty notebooks. This caused rename and file upload operations to fail after a mode switch because the notebook no longer existed server-side. Add a closingRef that is only set when the user explicitly closes the notebook, so auto-delete is skipped during mode switches and tab changes. Co-Authored-By: Claude Opus 4.6 --- .../src/components/LightSpeedChat.tsx | 6 ++++++ .../src/components/notebooks/NotebookView.tsx | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) 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 641cf2d906b..ffc14ed5c73 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -879,11 +879,14 @@ export const LightspeedChat = ({ ], ); + const notebookClosingRef = useRef(false); + const handleCreateNotebook = useCallback(() => { createNotebookMutation.mutate( { name: UNTITLED_NOTEBOOK_NAME }, { onSuccess: (session: NotebookSession) => { + notebookClosingRef.current = false; if (isFullscreenMode) { navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); } else { @@ -895,6 +898,7 @@ export const LightspeedChat = ({ }, [createNotebookMutation, isFullscreenMode, navigate, setActiveNotebookId]); const handleCloseNotebook = useCallback(() => { + notebookClosingRef.current = true; if (isFullscreenMode) { navigate(`${LIGHTSPEED_PATH}/notebooks`); } else { @@ -2255,6 +2259,7 @@ export const LightspeedChat = ({ isUploadModalOpen={notebookUploadModalOpen} onUploadModalOpenChange={setNotebookUploadModalOpen} onUploadsInProgressChange={setNotebookUploadsInProgress} + closingRef={notebookClosingRef} /> )} {showNotebooksPanel && @@ -2285,6 +2290,7 @@ export const LightspeedChat = ({ openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} onSelectNotebook={(notebook: NotebookSession) => { + notebookClosingRef.current = false; if (isFullscreenMode) { navigate( `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 9995f005d38..6d0bb10f3b5 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -295,6 +295,7 @@ type NotebookViewProps = { isUploadModalOpen: boolean; onUploadModalOpenChange: (open: boolean) => void; onUploadsInProgressChange?: (inProgress: boolean) => void; + closingRef?: React.MutableRefObject; }; export const NotebookView = ({ @@ -315,6 +316,7 @@ export const NotebookView = ({ isUploadModalOpen, onUploadModalOpenChange, onUploadsInProgressChange, + closingRef, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); @@ -465,7 +467,17 @@ export const NotebookView = ({ }; useEffect(() => { + const closingRefCurrent = closingRef; return () => { + // Only auto-delete untitled empty notebooks when the user explicitly + // closed the notebook. Display-mode switches and tab changes also + // unmount this component but should preserve the notebook. + if (!closingRefCurrent?.current) { + queryClient.invalidateQueries({ + queryKey: ['notebooks', 'sessions'], + }); + return; + } const currentNotebook = autoDeleteRef.current; if ( currentNotebook.isUntitled && @@ -488,7 +500,7 @@ export const NotebookView = ({ }); } }; - }, [notebooksApi, sessionId, queryClient]); + }, [notebooksApi, sessionId, queryClient, closingRef]); const handleRenameDocument = useCallback( async (documentId: string, newTitle: string) => { From 5d325379d2bcd091d7b641c8e3ba80fae6bf38ec Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 20 Aug 2026 17:04:00 +0530 Subject: [PATCH 17/23] fix(intelligent-assistant): preserve notebook stream state across display mode switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add module-level stores for streaming state (messages, requestId, completion signal) so that mid-stream display mode switches (especially overlay→dock which unmount/remount in the same React render cycle) maintain stream continuity, working stop button, and correct recovery. - Shadow setConversations in useConversationMessages to relay token updates to a module-level live stream store - Add notebookStreamMeta store for requestId and stream-complete signal so callbacks on the unmounted instance can reach the new one - Fix recovery exit to use stream-complete signal instead of fragile message count comparison - Read conversationId from recovery state to avoid TEMP_CONVERSATION_ID after remount - Fix live-stream fallback to check completion flag instead of hardcoding wasStreaming: true Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 --- .../src/components/notebooks/NotebookView.tsx | 282 ++++++++++++++++-- .../src/hooks/useConversationMessages.ts | 35 ++- 2 files changed, 296 insertions(+), 21 deletions(-) diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 6d0bb10f3b5..7445b483330 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -57,7 +57,11 @@ import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage' import { useNotebookWelcomePrompts } from '../../hooks/useNotebookWelcomePrompts'; import { useStopConversation } from '../../hooks/useStopConversation'; import { useTranslation } from '../../hooks/useTranslation'; -import { NotebookSessionMetadata, SessionDocument } from '../../types'; +import { + NotebookSession, + NotebookSessionMetadata, + SessionDocument, +} from '../../types'; import { ChatbotFootnoteWithIcon } from '../../utils/lightspeed-chatbox-utils'; import { runFileUploads } from '../../utils/notebook-upload-runner'; import { LightspeedChatBox } from '../LightspeedChatBox'; @@ -298,6 +302,89 @@ type NotebookViewProps = { closingRef?: React.MutableRefObject; }; +// Module-level cache for preserving notebook streaming state across display mode switches. +// When the user switches modes mid-stream, the component unmounts but the async streaming +// loop continues in the background. This cache bridges the gap so the remounted component +// can show the accumulated messages while waiting for the background stream to complete. +const notebookStreamCache = new Map< + string, + { + messages: MessageProps[]; + conversationId: string; + wasStreaming: boolean; + } +>(); + +// Module-level store for relaying live streaming tokens across display mode +// switches. The background streaming loop writes here via onConversationsUpdate; +// the remounted component subscribes and displays tokens in real-time. +type StreamListener = (messages: MessageProps[]) => void; +const notebookLiveStreamMessages = new Map(); +const notebookLiveStreamListeners = new Map>(); + +function emitLiveStreamUpdate(sessionId: string, messages: MessageProps[]) { + notebookLiveStreamMessages.set(sessionId, messages); + notebookLiveStreamListeners.get(sessionId)?.forEach(l => l(messages)); +} + +function subscribeLiveStream( + sessionId: string, + listener: StreamListener, +): () => void { + if (!notebookLiveStreamListeners.has(sessionId)) { + notebookLiveStreamListeners.set(sessionId, new Set()); + } + notebookLiveStreamListeners.get(sessionId)!.add(listener); + const current = notebookLiveStreamMessages.get(sessionId); + if (current && current.length > 0) listener(current); + return () => { + notebookLiveStreamListeners.get(sessionId)?.delete(listener); + }; +} + +function clearLiveStream(sessionId: string) { + notebookLiveStreamMessages.delete(sessionId); + notebookLiveStreamListeners.delete(sessionId); +} + +// Module-level metadata for the background stream (requestId, completion). +// Callbacks on the dead instance write here; the remounted instance reads +// and subscribes so it can stop the stream and know when it finishes. +type StreamMeta = { requestId: string; complete: boolean }; +type MetaListener = (meta: StreamMeta) => void; +const notebookStreamMeta = new Map(); +const notebookStreamMetaListeners = new Map>(); + +function setStreamMeta(sessionId: string, update: Partial) { + const prev = notebookStreamMeta.get(sessionId) ?? { + requestId: '', + complete: false, + }; + const next = { ...prev, ...update }; + notebookStreamMeta.set(sessionId, next); + notebookStreamMetaListeners.get(sessionId)?.forEach(l => l(next)); +} + +function subscribeStreamMeta( + sessionId: string, + listener: MetaListener, +): () => void { + if (!notebookStreamMetaListeners.has(sessionId)) { + notebookStreamMetaListeners.set(sessionId, new Set()); + } + notebookStreamMetaListeners.get(sessionId)!.add(listener); + const current = notebookStreamMeta.get(sessionId); + if (current) listener(current); + return () => { + notebookStreamMetaListeners.get(sessionId)?.delete(listener); + }; +} + +function clearStreamMeta(sessionId: string) { + notebookStreamMeta.delete(sessionId); + notebookStreamMetaListeners.delete(sessionId); +} + export const NotebookView = ({ sessionId, notebookName = UNTITLED_NOTEBOOK_NAME, @@ -331,11 +418,34 @@ export const NotebookView = ({ 'intelligent-assistant.notebooks.queryDefaults.model', ) || ''; + const cachedStreamState = notebookStreamCache.get(sessionId); + // When overlay→dock happens in the same React render cycle, the cleanup + // effect that populates notebookStreamCache hasn't run yet. Fall back to + // the live stream store which is written synchronously during streaming. + let recoveryState = cachedStreamState; + if (!recoveryState) { + const liveStreamSnapshot = notebookLiveStreamMessages.get(sessionId); + if (liveStreamSnapshot && liveStreamSnapshot.length > 0) { + const meta = notebookStreamMeta.get(sessionId); + recoveryState = { + messages: liveStreamSnapshot, + conversationId: metadata?.conversation_id ?? TEMP_CONVERSATION_ID, + wasStreaming: !meta?.complete, + }; + } + } + const [conversationId, setConversationId] = useState( - metadata?.conversation_id ?? TEMP_CONVERSATION_ID, + metadata?.conversation_id ?? + recoveryState?.conversationId ?? + TEMP_CONVERSATION_ID, + ); + const [isSendButtonDisabled, setIsSendButtonDisabled] = useState( + recoveryState?.wasStreaming ?? false, + ); + const [requestId, setRequestId] = useState( + () => notebookStreamMeta.get(sessionId)?.requestId ?? '', ); - const [isSendButtonDisabled, setIsSendButtonDisabled] = useState(false); - const [requestId, setRequestId] = useState(''); const { mutate: stopConversation } = useStopConversation(); const wasStoppedByUserRef = useRef(false); const autoDeleteRef = useRef({ @@ -365,6 +475,8 @@ export const NotebookView = ({ const onComplete = useCallback( (message: string) => { setIsSendButtonDisabled(false); + clearLiveStream(sessionId); + setStreamMeta(sessionId, { complete: true }); if (!wasStoppedByUserRef.current) { setAnnouncement(`Message from Bot: ${message}`); } @@ -373,12 +485,28 @@ export const NotebookView = ({ queryKey: ['conversationMessages', conversationId], }); }, - [queryClient, conversationId], + [queryClient, conversationId, sessionId], ); - const onStart = useCallback((conv_id: string) => { - setConversationId(conv_id); - }, []); + const onStart = useCallback( + (conv_id: string) => { + setConversationId(conv_id); + queryClient.setQueryData( + ['notebooks', 'session', sessionId], + old => + old + ? { + ...old, + metadata: { ...old.metadata, conversation_id: conv_id }, + } + : old, + ); + queryClient.invalidateQueries({ + queryKey: ['conversationMessages', conv_id], + }); + }, + [queryClient, sessionId], + ); const createMessageAdapter = useCallback( async (vars: CreateMessageVariables) => { @@ -390,9 +518,32 @@ export const NotebookView = ({ [notebookCreateMessage, sessionId], ); - const onRequestIdReady = useCallback((rid: string) => { - setRequestId(rid); - }, []); + const onRequestIdReady = useCallback( + (rid: string, convId?: string) => { + setRequestId(rid); + setStreamMeta(sessionId, { requestId: rid }); + if (convId) { + queryClient.setQueryData( + ['notebooks', 'session', sessionId], + old => + old + ? { + ...old, + metadata: { ...old.metadata, conversation_id: convId }, + } + : old, + ); + } + }, + [queryClient, sessionId], + ); + + const onConversationsUpdate = useCallback( + (msgs: MessageProps[]) => { + emitLiveStreamUpdate(sessionId, msgs); + }, + [sessionId], + ); const { conversationMessages, handleInputPrompt, scrollToBottomRef } = useConversationMessages( @@ -405,18 +556,105 @@ export const NotebookView = ({ onStart, createMessageAdapter, onRequestIdReady, + onConversationsUpdate, ); - const [messages, setMessages] = - useState(conversationMessages); + const [messages, setMessages] = useState( + () => recoveryState?.messages ?? conversationMessages, + ); + + // Refs to capture latest values for cleanup functions + const messagesRef = useRef(messages); + messagesRef.current = messages; + const conversationIdRef = useRef(conversationId); + conversationIdRef.current = conversationId; + const isSendButtonDisabledRef = useRef(isSendButtonDisabled); + isSendButtonDisabledRef.current = isSendButtonDisabled; + + // Track whether we're recovering from a cached mode switch + const isRecoveringFromCacheRef = useRef(!!recoveryState); + const cachedMessageCountRef = useRef(recoveryState?.messages.length ?? 0); + + // Clear the cache entry after reading it on mount + useEffect(() => { + notebookStreamCache.delete(sessionId); + }, [sessionId]); + + // Subscribe to live streaming updates from the background loop during recovery + useEffect(() => { + if (!isRecoveringFromCacheRef.current) return undefined; + return subscribeLiveStream(sessionId, msgs => { + if (msgs.length > 0) { + setMessages(msgs); + } + }); + }, [sessionId]); + + // Subscribe to stream metadata so the new instance receives requestId + // updates and the stream-complete signal from the old instance's callbacks. + useEffect(() => { + return subscribeStreamMeta(sessionId, meta => { + if (meta.requestId) { + setRequestId(meta.requestId); + } + if (meta.complete && isRecoveringFromCacheRef.current) { + isRecoveringFromCacheRef.current = false; + cachedMessageCountRef.current = 0; + setIsSendButtonDisabled(false); + clearLiveStream(sessionId); + } + }); + }, [sessionId]); + + // Sync messages from the hook, with recovery-aware gating + useEffect(() => { + if (isRecoveringFromCacheRef.current) { + // During recovery, only switch to server data once the stream has + // actually completed. The count check alone is insufficient because the + // server may already have the inflight pair with empty/partial content. + const meta = notebookStreamMeta.get(sessionId); + if (meta?.complete) { + isRecoveringFromCacheRef.current = false; + cachedMessageCountRef.current = 0; + setMessages(conversationMessages); + setIsSendButtonDisabled(false); + clearLiveStream(sessionId); + } + } else { + setMessages(conversationMessages); + } + }, [conversationMessages, sessionId]); + + // Cache messages on unmount during mode switch (not intentional close) + useEffect(() => { + const closingRefCurrent = closingRef; + return () => { + if (!closingRefCurrent?.current && messagesRef.current.length > 0) { + notebookStreamCache.set(sessionId, { + messages: messagesRef.current, + conversationId: conversationIdRef.current, + wasStreaming: isSendButtonDisabledRef.current, + }); + } + }; + }, [sessionId, closingRef]); + // During recovery, sync conversationId from metadata if the background + // stream's onRequestIdReady updated the session cache after our unmount useEffect(() => { - setMessages(conversationMessages); - }, [conversationMessages]); + if ( + isRecoveringFromCacheRef.current && + metadata?.conversation_id && + conversationId === TEMP_CONVERSATION_ID + ) { + setConversationId(metadata.conversation_id); + } + }, [metadata?.conversation_id, conversationId]); const sendMessage = useCallback( (message: string | number) => { wasStoppedByUserRef.current = false; + setStreamMeta(sessionId, { requestId: '', complete: false }); setAnnouncement( t('conversation.announcement.userMessage' as any, { prompt: message.toString(), @@ -425,18 +663,21 @@ export const NotebookView = ({ handleInputPrompt(message.toString(), []); setIsSendButtonDisabled(true); }, - [handleInputPrompt, t], + [handleInputPrompt, sessionId, t], ); const handleStopButton = useCallback(() => { wasStoppedByUserRef.current = true; - if (requestId) { - stopConversation(requestId); + const rid = requestId || notebookStreamMeta.get(sessionId)?.requestId || ''; + if (rid) { + stopConversation(rid); setRequestId(''); + setStreamMeta(sessionId, { requestId: '', complete: true }); } setIsSendButtonDisabled(false); + clearLiveStream(sessionId); setAnnouncement(t('conversation.announcement.responseStopped')); - }, [requestId, stopConversation, t]); + }, [requestId, sessionId, stopConversation, t]); const notebookPrompts = useNotebookWelcomePrompts(); const welcomePrompts = notebookPrompts.map(title => ({ @@ -478,6 +719,9 @@ export const NotebookView = ({ }); return; } + notebookStreamCache.delete(sessionId); + clearLiveStream(sessionId); + clearStreamMeta(sessionId); const currentNotebook = autoDeleteRef.current; if ( currentNotebook.isUntitled && diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts index 77dbf4e95e3..0226e6486e4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts @@ -165,7 +165,8 @@ export const useConversationMessages = ( createMessageOverride?: ( vars: CreateMessageVariables, ) => Promise>, - onRequestIdReady?: (request_id: string) => void, + onRequestIdReady?: (request_id: string, conversation_id?: string) => void, + onConversationsUpdate?: (messages: MessageProps[], activeKey: string) => void, ): UseConversationMessagesReturn => { const theme = useTheme(); const botAvatar = @@ -193,6 +194,15 @@ export const useConversationMessages = ( // Track pending tool calls during streaming const pendingToolCalls = useRef>({}); + const onConversationsUpdateRef = useRef(onConversationsUpdate); + onConversationsUpdateRef.current = onConversationsUpdate; + + const conversationsRef = useRef(conversations); + conversationsRef.current = conversations; + + const setConversationsRef = useRef(setConversations); + setConversationsRef.current = setConversations; + useEffect(() => { if (currentConversation !== conversationId) { setCurrentConversation(conversationId); @@ -313,6 +323,26 @@ export const useConversationMessages = ( ? createTempToolCallsCacheSessionPrefix() : currentConversation; + // Shadow setConversations to relay updates to a module-level listener + // so streaming tokens continue flowing after display mode switches. + const _origSetConversations = setConversationsRef.current; + const _convTracker = { current: conversationsRef.current }; + // eslint-disable-next-line @typescript-eslint/no-shadow + const setConversations: typeof _origSetConversations = ( + updater: Conversations | ((prev: Conversations) => Conversations), + ) => { + const newState = + typeof updater === 'function' + ? updater(_convTracker.current) + : updater; + _convTracker.current = newState; + _origSetConversations(updater); + const msgs = newState[currentConversation] ?? []; + if (msgs.length > 0) { + onConversationsUpdateRef.current?.(msgs, currentConversation); + } + }; + const conversationTuple = [ createUserMessage({ avatar, @@ -387,12 +417,13 @@ export const useConversationMessages = ( const { event, data } = JSON.parse(jsonString); if (event === 'start') { requestId = data?.request_id; - onRequestIdReady?.(requestId); if (currentConversation === TEMP_CONVERSATION_ID) { // If the conversation is temp, we need to set the new conversation id newConversationId = data?.conversation_id; } + + onRequestIdReady?.(requestId, newConversationId || undefined); } // Handle tool_call event From f1e5e2dce27b8434b5e48f5c397c23f061b9424b Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 20 Aug 2026 19:51:38 +0530 Subject: [PATCH 18/23] fix(intelligent-assistant): fix notebook auto-delete, tab persistence, and content overflow Replace closingRef with modeSwitchRef to invert auto-delete logic so empty untitled notebooks are cleaned up on tab switch and navigation but preserved during display mode switches. Track activeNotebookId in fullscreen mode so switching to Chat and back reopens the same notebook. Add word-break styles to notebook message contents to prevent overflow in overlay/docked modes. Signed-off-by: Rohit Ratannagar Assisted-by: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.6 --- .../e2e-tests/lightspeed.conversation.test.ts | 27 ++++++++---- .../e2e-tests/pages/NotebookSurfacePage.ts | 29 ++++++++---- .../e2e-tests/utils/lightspeedE2eSetup.ts | 25 ++++++----- .../src/components/LightSpeedChat.tsx | 44 +++++++++++-------- .../components/notebooks/AddDocumentModal.tsx | 2 +- .../src/components/notebooks/NotebookView.tsx | 33 +++++++++----- 6 files changed, 102 insertions(+), 58 deletions(-) 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/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 c64e10d4ae3..189dda499ef 100644 --- a/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts +++ b/workspaces/intelligent-assistant/e2e-tests/utils/lightspeedE2eSetup.ts @@ -44,21 +44,26 @@ async function loginAsGuest(page: Page) { 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') { - try { + try { + if (process.env.APP_MODE !== 'nfs') { 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); + .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 }); } - } else { return; + } catch { + if (attempt === maxAttempts) throw new Error('loginAsGuest failed'); + await page.reload(); + await page.waitForTimeout(2000); } } } 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 ffc14ed5c73..0f69536f9e6 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -837,7 +837,11 @@ export const LightspeedChat = ({ setShellViewTab(nextTab); if (isFullscreenMode) { if (nextTab === 1) { - navigate(`${LIGHTSPEED_PATH}/notebooks`); + navigate( + activeNotebookId + ? `${LIGHTSPEED_PATH}/notebooks/${activeNotebookId}` + : `${LIGHTSPEED_PATH}/notebooks`, + ); } else { navigate( routeConversationId @@ -851,46 +855,54 @@ export const LightspeedChat = ({ } }; + const notebookModeSwitchRef = useRef(false); + const setDisplayModeFromHeader = useCallback( (mode: ChatbotDisplayMode) => { + // Only set the mode switch flag if the mode is actually changing + if (mode !== displayMode) { + notebookModeSwitchRef.current = true; + } if (mode !== ChatbotDisplayMode.embedded) { - if (activeTab === 1 && activeNotebook?.session_id) { - setActiveNotebookId(activeNotebook.session_id); + 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); } }, [ + displayMode, setDisplayMode, activeTab, - activeNotebook?.session_id, + routeNotebookId, + activeNotebookId, setActiveNotebookId, ], ); - const notebookClosingRef = useRef(false); - const handleCreateNotebook = useCallback(() => { createNotebookMutation.mutate( { name: UNTITLED_NOTEBOOK_NAME }, { onSuccess: (session: NotebookSession) => { - notebookClosingRef.current = false; + setActiveNotebookId(session.session_id); if (isFullscreenMode) { navigate(`${LIGHTSPEED_PATH}/notebooks/${session.session_id}`); - } else { - setActiveNotebookId(session.session_id); } }, }, @@ -898,11 +910,9 @@ export const LightspeedChat = ({ }, [createNotebookMutation, isFullscreenMode, navigate, setActiveNotebookId]); const handleCloseNotebook = useCallback(() => { - notebookClosingRef.current = true; + setActiveNotebookId(undefined); if (isFullscreenMode) { navigate(`${LIGHTSPEED_PATH}/notebooks`); - } else { - setActiveNotebookId(undefined); } refetchNotebooks(); }, [isFullscreenMode, navigate, refetchNotebooks, setActiveNotebookId]); @@ -2259,7 +2269,7 @@ export const LightspeedChat = ({ isUploadModalOpen={notebookUploadModalOpen} onUploadModalOpenChange={setNotebookUploadModalOpen} onUploadsInProgressChange={setNotebookUploadsInProgress} - closingRef={notebookClosingRef} + modeSwitchRef={notebookModeSwitchRef} /> )} {showNotebooksPanel && @@ -2290,13 +2300,11 @@ export const LightspeedChat = ({ openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} onSelectNotebook={(notebook: NotebookSession) => { - notebookClosingRef.current = false; + setActiveNotebookId(notebook.session_id); if (isFullscreenMode) { navigate( `${LIGHTSPEED_PATH}/notebooks/${notebook.session_id}`, ); - } else { - setActiveNotebookId(notebook.session_id); } }} onRename={handleRenameNotebook} 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 4794ab43312..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 @@ -112,7 +112,7 @@ const useStyles = makeStyles(theme => ({ }, dialogActionsCompact: { padding: '12px 16px !important', - justifyContent: 'flex-end', + justifyContent: 'flex-start', gap: theme.spacing(1), }, addButton: { diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 7445b483330..022c0c422cb 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -278,6 +278,11 @@ const useStyles = makeStyles(theme => ({ flexDirection: 'column', flex: 1, overflow: 'auto', + '& .pf-chatbot__message-contents': { + overflowX: 'hidden', + overflowWrap: 'break-word', + wordBreak: 'break-word', + }, }, })); @@ -299,7 +304,7 @@ type NotebookViewProps = { isUploadModalOpen: boolean; onUploadModalOpenChange: (open: boolean) => void; onUploadsInProgressChange?: (inProgress: boolean) => void; - closingRef?: React.MutableRefObject; + modeSwitchRef?: React.MutableRefObject; }; // Module-level cache for preserving notebook streaming state across display mode switches. @@ -403,7 +408,7 @@ export const NotebookView = ({ isUploadModalOpen, onUploadModalOpenChange, onUploadsInProgressChange, - closingRef, + modeSwitchRef, }: NotebookViewProps) => { const classes = useStyles(); const { t } = useTranslation(); @@ -625,11 +630,13 @@ export const NotebookView = ({ } }, [conversationMessages, sessionId]); - // Cache messages on unmount during mode switch (not intentional close) + // Cache messages on unmount only during display mode switches so the + // remounted instance can restore state. Tab switches, navigation, and + // explicit close do not remount the notebook. useEffect(() => { - const closingRefCurrent = closingRef; + const modeSwitchRefCurrent = modeSwitchRef; return () => { - if (!closingRefCurrent?.current && messagesRef.current.length > 0) { + if (modeSwitchRefCurrent?.current && messagesRef.current.length > 0) { notebookStreamCache.set(sessionId, { messages: messagesRef.current, conversationId: conversationIdRef.current, @@ -637,7 +644,7 @@ export const NotebookView = ({ }); } }; - }, [sessionId, closingRef]); + }, [sessionId, modeSwitchRef]); // During recovery, sync conversationId from metadata if the background // stream's onRequestIdReady updated the session cache after our unmount @@ -708,12 +715,14 @@ export const NotebookView = ({ }; useEffect(() => { - const closingRefCurrent = closingRef; + const modeSwitchRefCurrent = modeSwitchRef; return () => { - // Only auto-delete untitled empty notebooks when the user explicitly - // closed the notebook. Display-mode switches and tab changes also - // unmount this component but should preserve the notebook. - if (!closingRefCurrent?.current) { + // Display-mode switches remount the component but should preserve the + // notebook and its streaming state. All other unmount reasons (tab + // switch, navigation, explicit close) should clean up and auto-delete + // empty untitled notebooks. + if (modeSwitchRefCurrent?.current) { + modeSwitchRefCurrent.current = false; queryClient.invalidateQueries({ queryKey: ['notebooks', 'sessions'], }); @@ -744,7 +753,7 @@ export const NotebookView = ({ }); } }; - }, [notebooksApi, sessionId, queryClient, closingRef]); + }, [notebooksApi, sessionId, queryClient, modeSwitchRef]); const handleRenameDocument = useCallback( async (documentId: string, newTitle: string) => { From fdfc9c7d851006f0637054604873838a2c69a39b Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Mon, 24 Aug 2026 19:28:19 +0530 Subject: [PATCH 19/23] addressing comments Signed-off-by: rohitratannagar --- .../src/components/LightSpeedChat.tsx | 5 --- .../src/components/notebooks/NotebookView.tsx | 34 ------------------- 2 files changed, 39 deletions(-) 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 0f69536f9e6..3a2326af3f0 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -167,11 +167,6 @@ const useStyles = makeStyles(theme => ({ 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( diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 022c0c422cb..8c594816fbb 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -93,22 +93,6 @@ const useStyles = makeStyles(theme => ({ flex: 1, minHeight: 0, minWidth: 0, - '& .pf-v6-c-drawer__panel, & .pf-v5-c-drawer__panel': { - backgroundColor: - 'var(--pf-t--global--background--color--floating--default) !important', - }, - '& .pf-v6-c-drawer__panel-main, & .pf-v5-c-drawer__panel-main': { - backgroundColor: - 'var(--pf-t--global--background--color--floating--default) !important', - }, - '& .pf-v6-c-drawer__panel-body, & .pf-v5-c-drawer__panel-body': { - backgroundColor: - 'var(--pf-t--global--background--color--floating--default) !important', - }, - '& .pf-v6-c-drawer__splitter, & .pf-v5-c-drawer__splitter': { - backgroundColor: - 'var(--pf-t--global--background--color--floating--default)', - }, }, expandStrip: { display: 'flex', @@ -173,19 +157,6 @@ const useStyles = makeStyles(theme => ({ boxSizing: 'border-box', backgroundColor: 'var(--pf-t--global--background--color--floating--default)', - '& .pf-v6-c-alert, & .pf-v5-c-alert': { - backgroundColor: - 'var(--pf-t--global--background--color--secondary--default) !important', - }, - '& .pf-v6-c-alert__content, & .pf-v5-c-alert__content': { - backgroundColor: 'transparent !important', - }, - '& .pf-v6-c-alert__body, & .pf-v5-c-alert__body': { - backgroundColor: 'transparent !important', - }, - '& .pf-v6-c-alert__description, & .pf-v5-c-alert__description': { - backgroundColor: 'transparent !important', - }, }, notebookDisclaimerInner: { width: '95%', @@ -266,11 +237,6 @@ const useStyles = makeStyles(theme => ({ ? theme.palette.grey[100] : 'var(--pf-t--global--background--color--secondary--default)', }, - '& .pf-chatbot__button--send, & .pf-chatbot__button--microphone': { - '--pf-v6-c-button--BorderRadius': - 'var(--pf-t--global--border--radius--pill)', - borderRadius: 'var(--pf-t--global--border--radius--pill) !important', - }, }, chatContent: { minHeight: 0, From 40771641ea90e462058dd59b00c4d6534ad31994 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Wed, 26 Aug 2026 01:04:44 +0530 Subject: [PATCH 20/23] fixing the stream issues Signed-off-by: rohitratannagar --- .../src/api/NotebooksApiClient.ts | 2 + .../src/api/notebooksApi.ts | 1 + .../src/components/LightSpeedChat.tsx | 61 ++- .../components/LightspeedDrawerProvider.tsx | 31 +- .../__tests__/LightspeedChat.test.tsx | 23 +- .../notebooks/DeleteDocumentModal.tsx | 2 +- .../notebooks/DeleteNotebookModal.tsx | 2 +- .../notebooks/NotebookStreamProvider.tsx | 102 ++++ .../src/components/notebooks/NotebookView.tsx | 468 ++++-------------- .../__tests__/notebookStreamStore.test.ts | 465 +++++++++++++++++ .../notebooks/notebookStreamStore.ts | 434 ++++++++++++++++ .../notebooks/useCreateNotebookMessage.ts | 4 +- .../src/hooks/useConversationMessages.ts | 264 ++-------- .../src/hooks/useLightspeedProviderState.ts | 9 +- .../src/utils/stream-event-helpers.ts | 215 ++++++++ 15 files changed, 1447 insertions(+), 636 deletions(-) create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookStreamProvider.tsx create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/__tests__/notebookStreamStore.test.ts create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookStreamStore.ts create mode 100644 workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/stream-event-helpers.ts 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 3a2326af3f0..e68e409c84e 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/LightSpeedChat.tsx @@ -99,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'; @@ -125,6 +126,7 @@ 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, @@ -771,6 +773,8 @@ export const LightspeedChat = ({ 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); @@ -827,7 +831,34 @@ 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 (isFullscreenMode) { @@ -850,14 +881,8 @@ export const LightspeedChat = ({ } }; - const notebookModeSwitchRef = useRef(false); - const setDisplayModeFromHeader = useCallback( (mode: ChatbotDisplayMode) => { - // Only set the mode switch flag if the mode is actually changing - if (mode !== displayMode) { - notebookModeSwitchRef.current = true; - } if (mode !== ChatbotDisplayMode.embedded) { if (activeTab === 1) { const notebookId = routeNotebookId || activeNotebookId; @@ -881,7 +906,6 @@ export const LightspeedChat = ({ } }, [ - displayMode, setDisplayMode, activeTab, routeNotebookId, @@ -891,6 +915,7 @@ export const LightspeedChat = ({ ); const handleCreateNotebook = useCallback(() => { + maybeAutoDeleteScratchNotebook(); createNotebookMutation.mutate( { name: UNTITLED_NOTEBOOK_NAME }, { @@ -902,15 +927,28 @@ export const LightspeedChat = ({ }, }, ); - }, [createNotebookMutation, isFullscreenMode, navigate, setActiveNotebookId]); + }, [ + maybeAutoDeleteScratchNotebook, + createNotebookMutation, + isFullscreenMode, + navigate, + setActiveNotebookId, + ]); const handleCloseNotebook = useCallback(() => { + maybeAutoDeleteScratchNotebook(); setActiveNotebookId(undefined); if (isFullscreenMode) { navigate(`${LIGHTSPEED_PATH}/notebooks`); } refetchNotebooks(); - }, [isFullscreenMode, navigate, refetchNotebooks, setActiveNotebookId]); + }, [ + maybeAutoDeleteScratchNotebook, + isFullscreenMode, + navigate, + refetchNotebooks, + setActiveNotebookId, + ]); const handleRemoveNotebookAlert = (key: React.Key) => { setNotebookAlerts(prevAlerts => @@ -919,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 }, @@ -2264,7 +2305,6 @@ export const LightspeedChat = ({ isUploadModalOpen={notebookUploadModalOpen} onUploadModalOpenChange={setNotebookUploadModalOpen} onUploadsInProgressChange={setNotebookUploadsInProgress} - modeSwitchRef={notebookModeSwitchRef} /> )} {showNotebooksPanel && @@ -2295,6 +2335,7 @@ export const LightspeedChat = ({ openNotebookMenuId={openNotebookMenuId} setOpenNotebookMenuId={setOpenNotebookMenuId} onSelectNotebook={(notebook: NotebookSession) => { + maybeAutoDeleteScratchNotebook(); setActiveNotebookId(notebook.session_id); if (isFullscreenMode) { navigate( 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 3e7eb5f1db2..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: { @@ -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/__tests__/LightspeedChat.test.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/__tests__/LightspeedChat.test.tsx index e1ccf91d426..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" + /> + diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DeleteDocumentModal.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DeleteDocumentModal.tsx index 5bc1e10d997..3313bf83dea 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DeleteDocumentModal.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/DeleteDocumentModal.tsx @@ -127,7 +127,7 @@ export const DeleteDocumentModal = ({ {t('notebook.document.delete.title')} (null); + +/** + * Owns the notebook stream store above the display-mode remount boundary. + * Must be mounted where it is a common ancestor of every `LightSpeedChat` + * mount point (overlay/docked/fullscreen) — i.e. inside `LightspeedDrawerProvider`. + */ +export const NotebookStreamProvider = ({ children }: PropsWithChildren) => { + const storeRef = useRef(); + if (!storeRef.current) { + storeRef.current = createNotebookStreamStore(); + } + + useEffect(() => { + const store = storeRef.current; + // Abort every in-flight stream only when the whole assistant unmounts. + return () => store?.clearAll(); + }, []); + + return ( + + {children} + + ); +}; + +export function useNotebookStreamStore(): NotebookStreamStore { + const store = useContext(NotebookStreamContext); + if (!store) { + throw new Error( + 'useNotebookStreamStore must be used within a NotebookStreamProvider', + ); + } + return store; +} + +export interface UseNotebookStreamResult extends NotebookStreamSnapshot { + send: (params: NotebookSendParams) => void; + stop: () => void; + clear: () => void; +} + +/** + * Subscribe a component to a notebook session's stream. The returned snapshot + * survives remounts because the store lives above the unmount boundary. + */ +export function useNotebookStream(sessionId: string): UseNotebookStreamResult { + const store = useNotebookStreamStore(); + + const subscribe = useCallback( + (listener: () => void) => store.subscribe(sessionId, listener), + [store, sessionId], + ); + const getSnapshot = useCallback( + () => store.getSnapshot(sessionId), + [store, sessionId], + ); + + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + const send = useCallback( + (params: NotebookSendParams) => store.send(sessionId, params), + [store, sessionId], + ); + const stop = useCallback(() => store.stop(sessionId), [store, sessionId]); + const clear = useCallback(() => store.clear(sessionId), [store, sessionId]); + + return { ...snapshot, send, stop, clear }; +} diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx index 8c594816fbb..df5bdef6164 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/NotebookView.tsx @@ -19,12 +19,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { makeStyles, Typography } from '@material-ui/core'; -import { - ChatbotContent, - ChatbotFooter, - MessageBar, - MessageProps, -} from '@patternfly/chatbot'; +import { useTheme } from '@material-ui/core/styles'; +import { ChatbotContent, ChatbotFooter, MessageBar } from '@patternfly/chatbot'; import { Alert, Button, @@ -53,15 +49,13 @@ import { useRenameDocument } from '../../hooks/notebooks/useRenameDocument'; import { useRenameNotebookWithAlert } from '../../hooks/notebooks/useRenameNotebookWithAlert'; import { useUploadDocument } from '../../hooks/notebooks/useUploadDocument'; import { useConversationMessages } from '../../hooks/useConversationMessages'; -import { CreateMessageVariables } from '../../hooks/useCreateCoversationMessage'; import { useNotebookWelcomePrompts } from '../../hooks/useNotebookWelcomePrompts'; import { useStopConversation } from '../../hooks/useStopConversation'; import { useTranslation } from '../../hooks/useTranslation'; -import { - NotebookSession, - NotebookSessionMetadata, - SessionDocument, -} from '../../types'; +import botAvatarDark from '../../images/bot-avatar-dark.svg'; +import botAvatarLight from '../../images/bot-avatar.svg'; +import userAvatar from '../../images/user-avatar.svg'; +import { NotebookSessionMetadata, SessionDocument } from '../../types'; import { ChatbotFootnoteWithIcon } from '../../utils/lightspeed-chatbox-utils'; import { runFileUploads } from '../../utils/notebook-upload-runner'; import { LightspeedChatBox } from '../LightspeedChatBox'; @@ -69,6 +63,7 @@ import { ToastAlertGroup } from '../ToastAlertGroup'; import { AddDocumentModal } from './AddDocumentModal'; import { DeleteDocumentModal } from './DeleteDocumentModal'; import { DocumentSidebar } from './DocumentSidebar'; +import { useNotebookStream } from './NotebookStreamProvider'; import { OverwriteConfirmModal } from './OverwriteConfirmModal'; import { AddCircleFilledIcon, SidebarExpandIcon } from './SidebarCollapseIcon'; import { UploadResourceScreen } from './UploadResourceScreen'; @@ -82,7 +77,8 @@ const useStyles = makeStyles(theme => ({ minWidth: 0, width: '100%', overflow: 'hidden', - backgroundColor: 'var(--pf-t--global--background--color--primary--default)', + backgroundColor: + 'var(--pf-t--global--background--color--floating--default)', }, drawerContent: { display: 'flex', @@ -93,6 +89,10 @@ const useStyles = makeStyles(theme => ({ flex: 1, minHeight: 0, minWidth: 0, + '& .pf-v6-c-drawer__panel, & .pf-v5-c-drawer__panel': { + backgroundColor: + 'var(--pf-t--global--background--color--floating--default)', + }, }, expandStrip: { display: 'flex', @@ -134,7 +134,8 @@ const useStyles = makeStyles(theme => ({ minWidth: 0, }, drawerContentBody: { - backgroundColor: 'var(--pf-t--global--background--color--primary--default)', + backgroundColor: + 'var(--pf-t--global--background--color--floating--default)', display: 'flex', flexDirection: 'column', flex: 1, @@ -244,6 +245,8 @@ const useStyles = makeStyles(theme => ({ flexDirection: 'column', flex: 1, overflow: 'auto', + backgroundColor: + 'var(--pf-t--global--background--color--floating--default)', '& .pf-chatbot__message-contents': { overflowX: 'hidden', overflowWrap: 'break-word', @@ -270,92 +273,8 @@ type NotebookViewProps = { isUploadModalOpen: boolean; onUploadModalOpenChange: (open: boolean) => void; onUploadsInProgressChange?: (inProgress: boolean) => void; - modeSwitchRef?: React.MutableRefObject; }; -// Module-level cache for preserving notebook streaming state across display mode switches. -// When the user switches modes mid-stream, the component unmounts but the async streaming -// loop continues in the background. This cache bridges the gap so the remounted component -// can show the accumulated messages while waiting for the background stream to complete. -const notebookStreamCache = new Map< - string, - { - messages: MessageProps[]; - conversationId: string; - wasStreaming: boolean; - } ->(); - -// Module-level store for relaying live streaming tokens across display mode -// switches. The background streaming loop writes here via onConversationsUpdate; -// the remounted component subscribes and displays tokens in real-time. -type StreamListener = (messages: MessageProps[]) => void; -const notebookLiveStreamMessages = new Map(); -const notebookLiveStreamListeners = new Map>(); - -function emitLiveStreamUpdate(sessionId: string, messages: MessageProps[]) { - notebookLiveStreamMessages.set(sessionId, messages); - notebookLiveStreamListeners.get(sessionId)?.forEach(l => l(messages)); -} - -function subscribeLiveStream( - sessionId: string, - listener: StreamListener, -): () => void { - if (!notebookLiveStreamListeners.has(sessionId)) { - notebookLiveStreamListeners.set(sessionId, new Set()); - } - notebookLiveStreamListeners.get(sessionId)!.add(listener); - const current = notebookLiveStreamMessages.get(sessionId); - if (current && current.length > 0) listener(current); - return () => { - notebookLiveStreamListeners.get(sessionId)?.delete(listener); - }; -} - -function clearLiveStream(sessionId: string) { - notebookLiveStreamMessages.delete(sessionId); - notebookLiveStreamListeners.delete(sessionId); -} - -// Module-level metadata for the background stream (requestId, completion). -// Callbacks on the dead instance write here; the remounted instance reads -// and subscribes so it can stop the stream and know when it finishes. -type StreamMeta = { requestId: string; complete: boolean }; -type MetaListener = (meta: StreamMeta) => void; -const notebookStreamMeta = new Map(); -const notebookStreamMetaListeners = new Map>(); - -function setStreamMeta(sessionId: string, update: Partial) { - const prev = notebookStreamMeta.get(sessionId) ?? { - requestId: '', - complete: false, - }; - const next = { ...prev, ...update }; - notebookStreamMeta.set(sessionId, next); - notebookStreamMetaListeners.get(sessionId)?.forEach(l => l(next)); -} - -function subscribeStreamMeta( - sessionId: string, - listener: MetaListener, -): () => void { - if (!notebookStreamMetaListeners.has(sessionId)) { - notebookStreamMetaListeners.set(sessionId, new Set()); - } - notebookStreamMetaListeners.get(sessionId)!.add(listener); - const current = notebookStreamMeta.get(sessionId); - if (current) listener(current); - return () => { - notebookStreamMetaListeners.get(sessionId)?.delete(listener); - }; -} - -function clearStreamMeta(sessionId: string) { - notebookStreamMeta.delete(sessionId); - notebookStreamMetaListeners.delete(sessionId); -} - export const NotebookView = ({ sessionId, notebookName = UNTITLED_NOTEBOOK_NAME, @@ -374,9 +293,11 @@ export const NotebookView = ({ isUploadModalOpen, onUploadModalOpenChange, onUploadsInProgressChange, - modeSwitchRef, }: NotebookViewProps) => { const classes = useStyles(); + const theme = useTheme(); + const botAvatar = + theme.palette.type === 'dark' ? botAvatarDark : botAvatarLight; const { t } = useTranslation(); const queryClient = useQueryClient(); const configApi = useApi(configApiRef); @@ -389,43 +310,22 @@ export const NotebookView = ({ 'intelligent-assistant.notebooks.queryDefaults.model', ) || ''; - const cachedStreamState = notebookStreamCache.get(sessionId); - // When overlay→dock happens in the same React render cycle, the cleanup - // effect that populates notebookStreamCache hasn't run yet. Fall back to - // the live stream store which is written synchronously during streaming. - let recoveryState = cachedStreamState; - if (!recoveryState) { - const liveStreamSnapshot = notebookLiveStreamMessages.get(sessionId); - if (liveStreamSnapshot && liveStreamSnapshot.length > 0) { - const meta = notebookStreamMeta.get(sessionId); - recoveryState = { - messages: liveStreamSnapshot, - conversationId: metadata?.conversation_id ?? TEMP_CONVERSATION_ID, - wasStreaming: !meta?.complete, - }; - } - } - const [conversationId, setConversationId] = useState( - metadata?.conversation_id ?? - recoveryState?.conversationId ?? - TEMP_CONVERSATION_ID, - ); - const [isSendButtonDisabled, setIsSendButtonDisabled] = useState( - recoveryState?.wasStreaming ?? false, - ); - const [requestId, setRequestId] = useState( - () => notebookStreamMeta.get(sessionId)?.requestId ?? '', + metadata?.conversation_id ?? TEMP_CONVERSATION_ID, ); const { mutate: stopConversation } = useStopConversation(); - const wasStoppedByUserRef = useRef(false); - const autoDeleteRef = useRef({ - isUntitled: false, - isEmpty: true, - noPending: true, - noUploading: true, - noChat: true, - }); + + // Streaming lives in a store above the display-mode remount boundary, so it + // survives overlay/docked/fullscreen switches. This view only subscribes. + const { + messages: streamMessages, + status: streamStatus, + requestId, + send: sendNotebookStream, + stop: stopNotebookStream, + } = useNotebookStream(sessionId); + const isStreaming = streamStatus === 'streaming'; + const [announcement, setAnnouncement] = useState( undefined, ); @@ -443,214 +343,85 @@ export const NotebookView = ({ const { mutateAsync: renameDocument } = useRenameDocument(); - const onComplete = useCallback( - (message: string) => { - setIsSendButtonDisabled(false); - clearLiveStream(sessionId); - setStreamMeta(sessionId, { complete: true }); - if (!wasStoppedByUserRef.current) { - setAnnouncement(`Message from Bot: ${message}`); - } - wasStoppedByUserRef.current = false; - queryClient.invalidateQueries({ - queryKey: ['conversationMessages', conversationId], - }); - }, - [queryClient, conversationId, sessionId], - ); - - const onStart = useCallback( - (conv_id: string) => { - setConversationId(conv_id); - queryClient.setQueryData( - ['notebooks', 'session', sessionId], - old => - old - ? { - ...old, - metadata: { ...old.metadata, conversation_id: conv_id }, - } - : old, - ); - queryClient.invalidateQueries({ - queryKey: ['conversationMessages', conv_id], - }); - }, - [queryClient, sessionId], - ); - - const createMessageAdapter = useCallback( - async (vars: CreateMessageVariables) => { - return notebookCreateMessage({ - prompt: vars.prompt, - sessionId, - }); - }, - [notebookCreateMessage, sessionId], + // Read-only: persisted (server) messages + transform. Displayed when the + // store has no live/finished stream for this session. Sending is handled by + // the stream store, not this hook. + const { conversationMessages, scrollToBottomRef } = useConversationMessages( + conversationId, + userName, + notebookModel, + '', + avatar, ); - const onRequestIdReady = useCallback( - (rid: string, convId?: string) => { - setRequestId(rid); - setStreamMeta(sessionId, { requestId: rid }); - if (convId) { - queryClient.setQueryData( - ['notebooks', 'session', sessionId], - old => - old - ? { - ...old, - metadata: { ...old.metadata, conversation_id: convId }, - } - : old, - ); - } - }, - [queryClient, sessionId], - ); + // Show the store's transcript whenever there is an active or finished stream + // for this session; otherwise fall back to persisted server messages. + const messages = + streamStatus === 'idle' ? conversationMessages : streamMessages; - const onConversationsUpdate = useCallback( - (msgs: MessageProps[]) => { - emitLiveStreamUpdate(sessionId, msgs); - }, - [sessionId], - ); - - const { conversationMessages, handleInputPrompt, scrollToBottomRef } = - useConversationMessages( - conversationId, - userName, - notebookModel, - '', - avatar, - onComplete, - onStart, - createMessageAdapter, - onRequestIdReady, - onConversationsUpdate, - ); - - const [messages, setMessages] = useState( - () => recoveryState?.messages ?? conversationMessages, - ); - - // Refs to capture latest values for cleanup functions - const messagesRef = useRef(messages); - messagesRef.current = messages; - const conversationIdRef = useRef(conversationId); - conversationIdRef.current = conversationId; - const isSendButtonDisabledRef = useRef(isSendButtonDisabled); - isSendButtonDisabledRef.current = isSendButtonDisabled; - - // Track whether we're recovering from a cached mode switch - const isRecoveringFromCacheRef = useRef(!!recoveryState); - const cachedMessageCountRef = useRef(recoveryState?.messages.length ?? 0); - - // Clear the cache entry after reading it on mount - useEffect(() => { - notebookStreamCache.delete(sessionId); - }, [sessionId]); - - // Subscribe to live streaming updates from the background loop during recovery - useEffect(() => { - if (!isRecoveringFromCacheRef.current) return undefined; - return subscribeLiveStream(sessionId, msgs => { - if (msgs.length > 0) { - setMessages(msgs); - } - }); - }, [sessionId]); - - // Subscribe to stream metadata so the new instance receives requestId - // updates and the stream-complete signal from the old instance's callbacks. - useEffect(() => { - return subscribeStreamMeta(sessionId, meta => { - if (meta.requestId) { - setRequestId(meta.requestId); - } - if (meta.complete && isRecoveringFromCacheRef.current) { - isRecoveringFromCacheRef.current = false; - cachedMessageCountRef.current = 0; - setIsSendButtonDisabled(false); - clearLiveStream(sessionId); - } - }); - }, [sessionId]); - - // Sync messages from the hook, with recovery-aware gating - useEffect(() => { - if (isRecoveringFromCacheRef.current) { - // During recovery, only switch to server data once the stream has - // actually completed. The count check alone is insufficient because the - // server may already have the inflight pair with empty/partial content. - const meta = notebookStreamMeta.get(sessionId); - if (meta?.complete) { - isRecoveringFromCacheRef.current = false; - cachedMessageCountRef.current = 0; - setMessages(conversationMessages); - setIsSendButtonDisabled(false); - clearLiveStream(sessionId); - } - } else { - setMessages(conversationMessages); - } - }, [conversationMessages, sessionId]); - - // Cache messages on unmount only during display mode switches so the - // remounted instance can restore state. Tab switches, navigation, and - // explicit close do not remount the notebook. - useEffect(() => { - const modeSwitchRefCurrent = modeSwitchRef; - return () => { - if (modeSwitchRefCurrent?.current && messagesRef.current.length > 0) { - notebookStreamCache.set(sessionId, { - messages: messagesRef.current, - conversationId: conversationIdRef.current, - wasStreaming: isSendButtonDisabledRef.current, - }); - } - }; - }, [sessionId, modeSwitchRef]); - - // During recovery, sync conversationId from metadata if the background - // stream's onRequestIdReady updated the session cache after our unmount + // Keep the local conversation id in sync when the store resolves a temp + // conversation to its real id (written into the session query cache). useEffect(() => { if ( - isRecoveringFromCacheRef.current && metadata?.conversation_id && - conversationId === TEMP_CONVERSATION_ID + metadata.conversation_id !== conversationId ) { setConversationId(metadata.conversation_id); } }, [metadata?.conversation_id, conversationId]); + // Announce the completed bot response for screen readers. + const prevStatusRef = useRef(streamStatus); + useEffect(() => { + if (prevStatusRef.current === 'streaming' && streamStatus === 'complete') { + const last = streamMessages[streamMessages.length - 1]; + if (last?.role === 'bot' && last.content) { + setAnnouncement(`Message from Bot: ${last.content}`); + } + } + prevStatusRef.current = streamStatus; + }, [streamStatus, streamMessages]); + const sendMessage = useCallback( (message: string | number) => { - wasStoppedByUserRef.current = false; - setStreamMeta(sessionId, { requestId: '', complete: false }); + const text = message.toString(); + if (!text.trim()) return; setAnnouncement( - t('conversation.announcement.userMessage' as any, { - prompt: message.toString(), - }), + t('conversation.announcement.userMessage' as any, { prompt: text }), ); - handleInputPrompt(message.toString(), []); - setIsSendButtonDisabled(true); + sendNotebookStream({ + prompt: text, + seedMessages: messages, + conversationId, + userName, + avatar: avatar || userAvatar, + botAvatar, + selectedModel: notebookModel, + createMessage: (prompt: string, options?: { signal?: AbortSignal }) => + notebookCreateMessage({ prompt, sessionId, signal: options?.signal }), + }); }, - [handleInputPrompt, sessionId, t], + [ + sendNotebookStream, + messages, + conversationId, + userName, + avatar, + botAvatar, + notebookModel, + notebookCreateMessage, + sessionId, + t, + ], ); const handleStopButton = useCallback(() => { - wasStoppedByUserRef.current = true; - const rid = requestId || notebookStreamMeta.get(sessionId)?.requestId || ''; - if (rid) { - stopConversation(rid); - setRequestId(''); - setStreamMeta(sessionId, { requestId: '', complete: true }); + if (requestId) { + stopConversation(requestId); } - setIsSendButtonDisabled(false); - clearLiveStream(sessionId); + stopNotebookStream(); setAnnouncement(t('conversation.announcement.responseStopped')); - }, [requestId, sessionId, stopConversation, t]); + }, [requestId, stopConversation, stopNotebookStream, t]); const notebookPrompts = useNotebookWelcomePrompts(); const welcomePrompts = notebookPrompts.map(title => ({ @@ -672,55 +443,6 @@ export const NotebookView = ({ const [isOverwriteModalOpen, setIsOverwriteModalOpen] = useState(false); const [filesToAddToModal, setFilesToAddToModal] = useState([]); - autoDeleteRef.current = { - isUntitled: notebookName === UNTITLED_NOTEBOOK_NAME, - isEmpty: documents.length === 0 && completedFileNames.size === 0, - noPending: !pendingUploads.length, - noUploading: !uploadingFileNames.length, - noChat: conversationId === TEMP_CONVERSATION_ID, - }; - - useEffect(() => { - const modeSwitchRefCurrent = modeSwitchRef; - return () => { - // Display-mode switches remount the component but should preserve the - // notebook and its streaming state. All other unmount reasons (tab - // switch, navigation, explicit close) should clean up and auto-delete - // empty untitled notebooks. - if (modeSwitchRefCurrent?.current) { - modeSwitchRefCurrent.current = false; - queryClient.invalidateQueries({ - queryKey: ['notebooks', 'sessions'], - }); - return; - } - notebookStreamCache.delete(sessionId); - clearLiveStream(sessionId); - clearStreamMeta(sessionId); - const currentNotebook = autoDeleteRef.current; - if ( - currentNotebook.isUntitled && - currentNotebook.isEmpty && - currentNotebook.noPending && - currentNotebook.noUploading && - currentNotebook.noChat - ) { - notebooksApi - .deleteSession(sessionId) - .then(() => { - queryClient.invalidateQueries({ - queryKey: ['notebooks', 'sessions'], - }); - }) - .catch(() => {}); - } else { - queryClient.invalidateQueries({ - queryKey: ['notebooks', 'sessions'], - }); - } - }; - }, [notebooksApi, sessionId, queryClient, modeSwitchRef]); - const handleRenameDocument = useCallback( async (documentId: string, newTitle: string) => { try { @@ -979,7 +701,7 @@ export const NotebookView = ({ ref={scrollToBottomRef} welcomePrompts={[]} conversationId={conversationId} - isStreaming={isSendButtonDisabled} + isStreaming={isStreaming} topicRestrictionEnabled={topicRestrictionEnabled} showSourcesChipPopover /> @@ -1142,11 +864,11 @@ export const NotebookView = ({ ({ + __esModule: true, + default: { + setQueryData: jest.fn(), + invalidateQueries: jest.fn(), + }, +})); + +jest.mock('../../../hooks/toolCallsCacheStore', () => ({ + clearSharedToolCallsCacheSessionPrefix: jest.fn(), + migrateSharedToolCallsCacheSessionPrefixToConversation: jest.fn(), + setSharedToolCallsCache: jest.fn(), +})); + +jest.mock('../../../utils/stream-event-helpers', () => ({ + ...jest.requireActual('../../../utils/stream-event-helpers'), + createTempToolCallsCacheSessionPrefix: () => 'lightspeed-temp:test-prefix', +})); + +function encodeSSE(event: string, data: Record): Uint8Array { + const json = JSON.stringify({ event, data }); + return new TextEncoder().encode(`data:${json}\n\n`); +} + +function createMockReader( + chunks: Uint8Array[], +): ReadableStreamDefaultReader { + let index = 0; + return { + read: jest.fn(async () => { + if (index >= chunks.length) { + return { done: true as const, value: undefined }; + } + const value = chunks[index++]; + return { done: false as const, value }; + }), + releaseLock: jest.fn(), + cancel: jest.fn(), + closed: Promise.resolve(undefined), + } as unknown as ReadableStreamDefaultReader; +} + +function createDeferredReader(): { + reader: ReadableStreamDefaultReader; + push: (chunk: Uint8Array) => void; + end: () => void; +} { + const pending: Array<{ + resolve: (r: ReadableStreamReadResult) => void; + }> = []; + const buffer: Uint8Array[] = []; + let ended = false; + + const reader = { + read: jest.fn( + () => + new Promise>(resolve => { + if (buffer.length > 0) { + resolve({ done: false, value: buffer.shift()! }); + } else if (ended) { + resolve({ done: true, value: undefined } as any); + } else { + pending.push({ resolve }); + } + }), + ), + releaseLock: jest.fn(), + cancel: jest.fn(), + closed: Promise.resolve(undefined), + } as unknown as ReadableStreamDefaultReader; + + return { + reader, + push(chunk: Uint8Array) { + if (pending.length > 0) { + pending.shift()!.resolve({ done: false, value: chunk }); + } else { + buffer.push(chunk); + } + }, + end() { + ended = true; + for (const p of pending) { + p.resolve({ done: true, value: undefined } as any); + } + pending.length = 0; + }, + }; +} + +function makeParams( + overrides?: Partial, +): NotebookSendParams { + return { + prompt: 'hello', + seedMessages: [], + conversationId: 'conv-1', + userName: 'user', + avatar: 'avatar.png', + botAvatar: 'bot.png', + selectedModel: 'model-a', + createMessage: jest.fn(), + ...overrides, + }; +} + +async function flushMicrotasks() { + await new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('notebookStreamStore', () => { + let store: NotebookStreamStore; + + beforeEach(() => { + store = createNotebookStreamStore(); + }); + + describe('subscribe / getSnapshot', () => { + it('returns idle snapshot for unknown sessions', () => { + const snap = store.getSnapshot('unknown'); + expect(snap.status).toBe('idle'); + expect(snap.messages).toEqual([]); + }); + + it('notifies listeners on state change', () => { + const listener = jest.fn(); + store.subscribe('s1', listener); + + const reader = createMockReader([encodeSSE('token', { token: 'hi' })]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + + expect(listener).toHaveBeenCalled(); + }); + + it('unsubscribe removes listener', async () => { + const listener = jest.fn(); + const unsub = store.subscribe('s1', listener); + unsub(); + + const reader = createMockReader([encodeSSE('token', { token: 'hi' })]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('snapshot survives subscribe/unsubscribe/re-subscribe (remount)', async () => { + const reader = createMockReader([encodeSSE('token', { token: 'hi' })]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + const snap1 = store.getSnapshot('s1'); + expect(snap1.status).toBe('complete'); + + const listener1 = jest.fn(); + const unsub = store.subscribe('s1', listener1); + unsub(); + + const listener2 = jest.fn(); + store.subscribe('s1', listener2); + const snap2 = store.getSnapshot('s1'); + expect(snap2).toBe(snap1); + }); + }); + + describe('send — happy path', () => { + it('streams tokens and reaches complete status', async () => { + const reader = createMockReader([ + encodeSSE('start', { request_id: 'req-1' }), + encodeSSE('token', { token: 'Hello' }), + encodeSSE('token', { token: ' world' }), + encodeSSE('end', { referenced_documents: [] }), + ]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + const snap = store.getSnapshot('s1'); + expect(snap.status).toBe('complete'); + expect(snap.requestId).toBe('req-1'); + const lastMsg = snap.messages[snap.messages.length - 1]; + expect(lastMsg.content).toContain('Hello world'); + }); + + it('passes abort signal to createMessage', async () => { + const reader = createMockReader([]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + expect(createMessage).toHaveBeenCalledWith('hello', { + signal: expect.any(AbortSignal), + }); + }); + + it('rejects empty prompts', () => { + const createMessage = jest.fn(); + store.send('s1', makeParams({ prompt: ' ', createMessage })); + expect(createMessage).not.toHaveBeenCalled(); + expect(store.getSnapshot('s1').status).toBe('idle'); + }); + }); + + describe('stop — abort race protection', () => { + it('stop sets status to stopped and it is not overwritten', async () => { + const { reader, push, end } = createDeferredReader(); + const createMessage = jest.fn().mockResolvedValue(reader); + + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + expect(store.getSnapshot('s1').status).toBe('streaming'); + + store.stop('s1'); + expect(store.getSnapshot('s1').status).toBe('stopped'); + + push(encodeSSE('token', { token: 'late data' })); + end(); + await flushMicrotasks(); + + expect(store.getSnapshot('s1').status).toBe('stopped'); + }); + + it('stop sets isLoading to false on the last message', async () => { + const { reader, push } = createDeferredReader(); + const createMessage = jest.fn().mockResolvedValue(reader); + + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + push(encodeSSE('start', { request_id: 'req-1' })); + await flushMicrotasks(); + + store.stop('s1'); + const snap = store.getSnapshot('s1'); + const lastMsg = snap.messages[snap.messages.length - 1]; + expect(lastMsg.isLoading).toBe(false); + }); + }); + + describe('rapid send — generation protection', () => { + it('old stream cannot overwrite new stream messages', async () => { + const { + reader: reader1, + push: push1, + end: end1, + } = createDeferredReader(); + const { + reader: reader2, + push: push2, + end: end2, + } = createDeferredReader(); + + const createMessage1 = jest.fn().mockResolvedValue(reader1); + const createMessage2 = jest.fn().mockResolvedValue(reader2); + + store.send( + 's1', + makeParams({ prompt: 'first', createMessage: createMessage1 }), + ); + await flushMicrotasks(); + + store.send( + 's1', + makeParams({ prompt: 'second', createMessage: createMessage2 }), + ); + await flushMicrotasks(); + + push1(encodeSSE('token', { token: 'stale data from run 1' })); + end1(); + await flushMicrotasks(); + + push2(encodeSSE('token', { token: 'fresh' })); + end2(); + await flushMicrotasks(); + + const snap = store.getSnapshot('s1'); + expect(snap.status).toBe('complete'); + const lastMsg = snap.messages[snap.messages.length - 1]; + expect(lastMsg.content).toContain('fresh'); + expect(lastMsg.content).not.toContain('stale'); + }); + + it('old stream does not emit complete after new stream starts', async () => { + const { reader: reader1, end: end1 } = createDeferredReader(); + const { + reader: reader2, + push: push2, + end: end2, + } = createDeferredReader(); + + const createMessage1 = jest.fn().mockResolvedValue(reader1); + const createMessage2 = jest.fn().mockResolvedValue(reader2); + + store.send('s1', makeParams({ createMessage: createMessage1 })); + await flushMicrotasks(); + + store.send('s1', makeParams({ createMessage: createMessage2 })); + await flushMicrotasks(); + + end1(); + await flushMicrotasks(); + + expect(store.getSnapshot('s1').status).toBe('streaming'); + + push2(encodeSSE('end', { referenced_documents: [] })); + end2(); + await flushMicrotasks(); + + expect(store.getSnapshot('s1').status).toBe('complete'); + }); + }); + + describe('temp → real conversation id migration', () => { + it('migrates conversation id when server returns one', async () => { + const { migrateSharedToolCallsCacheSessionPrefixToConversation } = + jest.requireMock('../../../hooks/toolCallsCacheStore'); + const { default: queryClient } = jest.requireMock( + '../../../utils/queryClient', + ); + + const reader = createMockReader([ + encodeSSE('start', { + request_id: 'req-1', + conversation_id: 'real-conv-123', + }), + encodeSSE('token', { token: 'hi' }), + encodeSSE('end', { referenced_documents: [] }), + ]); + const createMessage = jest.fn().mockResolvedValue(reader); + + store.send( + 's1', + makeParams({ + conversationId: TEMP_CONVERSATION_ID, + createMessage, + }), + ); + await flushMicrotasks(); + + const snap = store.getSnapshot('s1'); + expect(snap.conversationId).toBe('real-conv-123'); + expect(snap.status).toBe('complete'); + + expect( + migrateSharedToolCallsCacheSessionPrefixToConversation, + ).toHaveBeenCalledWith('lightspeed-temp:test-prefix', 'real-conv-123'); + + expect(queryClient.setQueryData).toHaveBeenCalled(); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['conversationMessages', 'real-conv-123'], + }); + }); + + it('clears temp cache when no real id is returned', async () => { + const { clearSharedToolCallsCacheSessionPrefix } = jest.requireMock( + '../../../hooks/toolCallsCacheStore', + ); + + const reader = createMockReader([ + encodeSSE('start', { request_id: 'req-1' }), + encodeSSE('end', { referenced_documents: [] }), + ]); + const createMessage = jest.fn().mockResolvedValue(reader); + + store.send( + 's1', + makeParams({ + conversationId: TEMP_CONVERSATION_ID, + createMessage, + }), + ); + await flushMicrotasks(); + + expect(clearSharedToolCallsCacheSessionPrefix).toHaveBeenCalledWith( + 'lightspeed-temp:test-prefix', + ); + }); + }); + + describe('error handling', () => { + it('sets error status when createMessage throws', async () => { + const createMessage = jest + .fn() + .mockRejectedValue(new Error('network failure')); + + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + const snap = store.getSnapshot('s1'); + expect(snap.status).toBe('error'); + }); + + it('does not set error status if aborted before error', async () => { + const createMessage = jest.fn().mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + throw new Error('network failure'); + }); + + store.send('s1', makeParams({ createMessage })); + store.stop('s1'); + await flushMicrotasks(); + + const snap = store.getSnapshot('s1'); + expect(snap.status).toBe('stopped'); + }); + }); + + describe('clear / clearAll', () => { + it('clear resets a session to idle', async () => { + const reader = createMockReader([encodeSSE('token', { token: 'hi' })]); + const createMessage = jest.fn().mockResolvedValue(reader); + store.send('s1', makeParams({ createMessage })); + await flushMicrotasks(); + + store.clear('s1'); + expect(store.getSnapshot('s1').status).toBe('idle'); + }); + + it('clearAll resets all sessions', async () => { + const reader1 = createMockReader([encodeSSE('token', { token: 'a' })]); + const reader2 = createMockReader([encodeSSE('token', { token: 'b' })]); + + store.send( + 's1', + makeParams({ createMessage: jest.fn().mockResolvedValue(reader1) }), + ); + store.send( + 's2', + makeParams({ createMessage: jest.fn().mockResolvedValue(reader2) }), + ); + await flushMicrotasks(); + + store.clearAll(); + expect(store.getSnapshot('s1').status).toBe('idle'); + expect(store.getSnapshot('s2').status).toBe('idle'); + }); + }); +}); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookStreamStore.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookStreamStore.ts new file mode 100644 index 00000000000..b4de5dfe82a --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/notebooks/notebookStreamStore.ts @@ -0,0 +1,434 @@ +/* + * 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 { MessageProps } from '@patternfly/chatbot'; + +import { TEMP_CONVERSATION_ID } from '../../const'; +import { + clearSharedToolCallsCacheSessionPrefix, + migrateSharedToolCallsCacheSessionPrefixToConversation, + setSharedToolCallsCache, +} from '../../hooks/toolCallsCacheStore'; +import { NotebookSession, ToolCall } from '../../types'; +import { + createBotMessage, + createUserMessage, + getTimestamp, + normalizeChatUserInput, + transformDocumentsToSources, +} from '../../utils/lightspeed-chatbox-utils'; +import queryClient from '../../utils/queryClient'; +import { + applyToolResultToToolCalls, + createTempToolCallsCacheSessionPrefix, + normalizeToolCalls, + parseSSEBuffer, + parseToolCallFromEvent, + parseToolResultFromEvent, + toolCallIdKey, +} from '../../utils/stream-event-helpers'; + +/** + * External store that owns notebook message streaming, keyed by notebook + * `sessionId`. The store lives above the display-mode remount boundary + * (mounted by {@link NotebookStreamProvider}) so a stream started in one mode + * (overlay/docked/fullscreen) keeps running when the notebook view unmounts and + * remounts in another mode. The view is a pure subscriber: it never owns the + * stream lifecycle, and the stream is aborted only on explicit intent + * (stop/close/delete/provider-unmount), never on unmount. + */ + +export type ExtendedMessageProps = MessageProps & { toolCalls?: ToolCall[] }; + +export type NotebookStreamStatus = + 'idle' | 'streaming' | 'complete' | 'stopped' | 'error'; + +export interface NotebookStreamSnapshot { + messages: ExtendedMessageProps[]; + conversationId: string; + requestId: string; + status: NotebookStreamStatus; +} + +export interface NotebookSendParams { + prompt: string; + /** Current on-screen messages the new turn is appended to. */ + seedMessages: ExtendedMessageProps[]; + /** Current conversation id (may be the provisional temp id). */ + conversationId: string; + userName?: string; + avatar: string; + botAvatar: string; + selectedModel: string; + /** Bound to the notebook session; returns the SSE reader. */ + createMessage: ( + prompt: string, + options?: { signal?: AbortSignal }, + ) => Promise>; +} + +export interface NotebookStreamStore { + subscribe(sessionId: string, listener: () => void): () => void; + getSnapshot(sessionId: string): NotebookStreamSnapshot; + send(sessionId: string, params: NotebookSendParams): void; + stop(sessionId: string): void; + clear(sessionId: string): void; + clearAll(): void; +} + +const IDLE_SNAPSHOT: NotebookStreamSnapshot = Object.freeze({ + messages: [], + conversationId: '', + requestId: '', + status: 'idle', +}); + +interface Entry { + snapshot: NotebookStreamSnapshot; + listeners: Set<() => void>; + abort?: AbortController; + generation: number; + pendingToolCalls: Record; +} + +export function createNotebookStreamStore(): NotebookStreamStore { + const entries = new Map(); + + const ensureEntry = (sessionId: string): Entry => { + let entry = entries.get(sessionId); + if (!entry) { + entry = { + snapshot: IDLE_SNAPSHOT, + listeners: new Set(), + generation: 0, + pendingToolCalls: {}, + }; + entries.set(sessionId, entry); + } + return entry; + }; + + const emit = ( + sessionId: string, + next: Partial & { + messages?: ExtendedMessageProps[]; + }, + ) => { + const entry = ensureEntry(sessionId); + entry.snapshot = { ...entry.snapshot, ...next }; + entry.listeners.forEach(l => l()); + }; + + const updateSessionConversationId = ( + sessionId: string, + conversationId: string, + ) => { + queryClient.setQueryData( + ['notebooks', 'session', sessionId], + old => + old + ? { + ...old, + metadata: { ...old.metadata, conversation_id: conversationId }, + } + : old, + ); + }; + + async function runStream(sessionId: string, params: NotebookSendParams) { + const { + prompt, + seedMessages, + userName, + avatar, + botAvatar, + selectedModel, + createMessage, + } = params; + + const entry = ensureEntry(sessionId); + const signal = entry.abort!.signal; + const runGeneration = entry.generation; + entry.pendingToolCalls = {}; + + const isStale = () => signal.aborted || entry.generation !== runGeneration; + + let convId = params.conversationId; + let requestId = ''; + let newConversationId = ''; + const startedOnTemp = convId === TEMP_CONVERSATION_ID; + const toolCallsCacheKeyPrefix = startedOnTemp + ? createTempToolCallsCacheSessionPrefix() + : convId; + + let working: ExtendedMessageProps[] = [ + ...seedMessages, + createUserMessage({ + avatar, + name: userName, + content: prompt, + timestamp: getTimestamp(Date.now()) ?? '', + }), + createBotMessage({ + avatar: botAvatar, + isLoading: true, + name: selectedModel, + content: '', + timestamp: '', + }), + ]; + + const commit = (status: NotebookStreamStatus = 'streaming') => + emit(sessionId, { + messages: [...working], + conversationId: convId, + requestId, + status, + }); + + const updateLast = ( + fn: (msg: ExtendedMessageProps) => ExtendedMessageProps, + ) => { + if (working.length === 0) return; + const lastIndex = working.length - 1; + working = [...working.slice(0, lastIndex), fn({ ...working[lastIndex] })]; + }; + + commit('streaming'); + + const finalMessages: string[] = []; + let buffer = ''; + + try { + const reader = await createMessage(prompt, { signal }); + if (isStale()) return; + + const decoder = new TextDecoder('utf-8'); + let streamEnded = false; + + while (!streamEnded) { + if (isStale()) return; + + const { value, done } = await reader.read(); + if (isStale()) return; + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const { events: parsedEvents, remainder } = parseSSEBuffer(buffer); + buffer = remainder; + + for (const { event, data } of parsedEvents) { + if (event === 'start') { + requestId = data?.request_id; + if (startedOnTemp) { + newConversationId = data?.conversation_id; + if (newConversationId) { + updateSessionConversationId(sessionId, newConversationId); + } + } + commit('streaming'); + } + + if (event === 'tool_call') { + const toolCall = parseToolCallFromEvent(data); + + if (toolCall && data.id !== null) { + entry.pendingToolCalls[toolCallIdKey(data.id)] = toolCall; + const lastIndex = working.length - 1; + const messageIndex = Math.floor(lastIndex / 2); + updateLast(last => { + const nextToolCalls = [ + ...normalizeToolCalls(last.toolCalls), + toolCall, + ]; + setSharedToolCallsCache( + `${toolCallsCacheKeyPrefix}-${messageIndex}`, + nextToolCalls, + ); + return { ...last, toolCalls: nextToolCalls }; + }); + commit('streaming'); + } + } + + if (event === 'tool_result') { + const result = parseToolResultFromEvent( + data, + entry.pendingToolCalls, + ); + + if (result) { + const lastIndex = working.length - 1; + const messageIndex = Math.floor(lastIndex / 2); + updateLast(last => { + const updatedToolCalls = applyToolResultToToolCalls( + last.toolCalls || [], + result, + ); + setSharedToolCallsCache( + `${toolCallsCacheKeyPrefix}-${messageIndex}`, + updatedToolCalls, + ); + return { ...last, toolCalls: updatedToolCalls }; + }); + delete entry.pendingToolCalls[result.toolIdKey]; + commit('streaming'); + } + } + + if (event === 'token') { + const content = data?.token || ''; + finalMessages.push(content); + updateLast(last => { + const next = { ...last }; + if ((next.content ?? '').trim().length > 0) { + next.isLoading = false; + } + next.content = (next.content ?? '') + content; + next.name = data?.response_metadata?.model || selectedModel; + next.timestamp = getTimestamp( + data?.response_metadata?.created_at || Date.now(), + ); + return next; + }); + commit('streaming'); + } + + if (event === 'interrupted') { + if (startedOnTemp && data?.conversation_id) { + newConversationId = data.conversation_id; + } + updateLast(last => ({ ...last, isLoading: false })); + commit('streaming'); + streamEnded = true; + break; + } + + if (event === 'end') { + const documents = data?.referenced_documents || []; + const sources = transformDocumentsToSources(documents); + updateLast(last => ({ + ...last, + isLoading: false, + ...(sources ? { sources } : {}), + })); + commit('streaming'); + } + } + if (streamEnded) break; + } + } catch (e: any) { + if (isStale()) return; + updateLast(last => ({ + ...last, + isLoading: false, + content: `${last.content ?? ''}${e}`, + error: { title: e?.message }, + timestamp: getTimestamp(Date.now()), + })); + finalMessages.push(`${e}`); + emit(sessionId, { + messages: [...working], + conversationId: newConversationId || convId, + requestId, + status: 'error', + }); + return; + } + + if (isStale()) return; + + // Migrate temp conversation to its real id (tool-call cache + session cache). + if (startedOnTemp && newConversationId) { + migrateSharedToolCallsCacheSessionPrefixToConversation( + toolCallsCacheKeyPrefix, + newConversationId, + ); + convId = newConversationId; + updateSessionConversationId(sessionId, newConversationId); + } else if (startedOnTemp) { + clearSharedToolCallsCacheSessionPrefix(toolCallsCacheKeyPrefix); + } + + // Status stays 'complete' (never reverts to 'idle') so the in-memory + // transcript is preserved across remounts. The view layer merges this with + // react-query's refetch which fires after invalidation below. If the + // notebook is later closed/deleted, clear() drops the entry entirely and + // getSnapshot falls back to IDLE_SNAPSHOT; the next open re-fetches from + // the server via useConversationMessages. + commit('complete'); + + queryClient.invalidateQueries({ + queryKey: ['conversationMessages', convId], + }); + queryClient.invalidateQueries({ queryKey: ['notebooks', 'sessions'] }); + } + + return { + subscribe(sessionId, listener) { + const entry = ensureEntry(sessionId); + entry.listeners.add(listener); + return () => { + entry.listeners.delete(listener); + }; + }, + + getSnapshot(sessionId) { + return entries.get(sessionId)?.snapshot ?? IDLE_SNAPSHOT; + }, + + send(sessionId, params) { + if (!normalizeChatUserInput(params.prompt)) return; + const entry = ensureEntry(sessionId); + entry.abort?.abort(); + entry.abort = new AbortController(); + entry.generation++; + void runStream(sessionId, params); + }, + + stop(sessionId) { + const entry = entries.get(sessionId); + if (!entry) return; + entry.abort?.abort(); + const messages = entry.snapshot.messages; + const lastIndex = messages.length - 1; + const nextMessages = + lastIndex >= 0 + ? [ + ...messages.slice(0, lastIndex), + { ...messages[lastIndex], isLoading: false }, + ] + : messages; + emit(sessionId, { + messages: nextMessages, + requestId: '', + status: 'stopped', + }); + }, + + clear(sessionId) { + const entry = entries.get(sessionId); + if (!entry) return; + entry.abort?.abort(); + entries.delete(sessionId); + }, + + clearAll() { + entries.forEach(entry => entry.abort?.abort()); + entries.clear(); + }, + }; +} diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useCreateNotebookMessage.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useCreateNotebookMessage.ts index 2765540b06c..b93ff78e90a 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useCreateNotebookMessage.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/notebooks/useCreateNotebookMessage.ts @@ -23,6 +23,7 @@ import { notebooksApiRef } from '../../api/notebooksApi'; export type CreateNotebookMessageVariables = { prompt: string; sessionId: string; + signal?: AbortSignal; }; export const useCreateNotebookMessage = (): UseMutationResult< @@ -36,12 +37,13 @@ export const useCreateNotebookMessage = (): UseMutationResult< mutationFn: async ({ prompt, sessionId, + signal, }: CreateNotebookMessageVariables) => { if (!sessionId) { throw new Error('Failed to generate AI response'); } - return await notebooksApi.querySession(sessionId, prompt); + return await notebooksApi.querySession(sessionId, prompt, { signal }); }, onError: error => { // eslint-disable-next-line diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts index 0226e6486e4..8e51325c9e1 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useConversationMessages.ts @@ -37,6 +37,15 @@ import { normalizeChatUserInput, transformDocumentsToSources, } from '../utils/lightspeed-chatbox-utils'; +import { + applyToolResultToToolCalls, + createTempToolCallsCacheSessionPrefix, + normalizeToolCalls, + parseSSEBuffer, + parseToolCallFromEvent, + parseToolResultFromEvent, + toolCallIdKey, +} from '../utils/stream-event-helpers'; import { clearSharedToolCallsCacheSessionPrefix, getSharedToolCallsCache, @@ -48,58 +57,6 @@ import { useCreateConversationMessage, } from './useCreateCoversationMessage'; -const toolCallIdKey = (id: string | number): string => { - return String(id); -}; - -const normalizeToolCalls = ( - calls: (ToolCall | undefined)[] | undefined, -): ToolCall[] => (calls ?? []).filter((tc): tc is ToolCall => tc !== null); - -const isMcpStyleToolCallPayload = ( - data: Record | undefined, -): boolean => { - return ( - !!data && - typeof data.name === 'string' && - data.name.trim().length > 0 && - data.id !== null - ); -}; - -/** Legacy tool_result uses data.token with at least tool_name and response. */ -const isLegacyToolResultToken = ( - token: unknown, -): token is { tool_name: string; response?: unknown } => { - return ( - !!token && - typeof token === 'object' && - !Array.isArray(token) && - typeof (token as { tool_name?: string }).tool_name === 'string' && - (token as { tool_name: string }).tool_name.length > 0 - ); -}; - -let tempToolCallsCachePrefixFallbackSeq = 0; - -/** Unique prefix per temp send so late streams cannot migrate another session's tool cache. */ -function createTempToolCallsCacheSessionPrefix(): string { - const suffix = - globalThis.crypto?.randomUUID?.() ?? - `${Date.now()}-${++tempToolCallsCachePrefixFallbackSeq}`; - return `lightspeed-temp:${suffix}`; -} - -const legacyToolResultToString = (response: unknown): string => { - if (!response) return ''; - if (typeof response === 'string') return response; - try { - return JSON.stringify(response); - } catch { - return String(response); - } -}; - // Fetch all conversation messages export const useFetchConversationMessages = ( currentConversation: string, @@ -166,7 +123,6 @@ export const useConversationMessages = ( vars: CreateMessageVariables, ) => Promise>, onRequestIdReady?: (request_id: string, conversation_id?: string) => void, - onConversationsUpdate?: (messages: MessageProps[], activeKey: string) => void, ): UseConversationMessagesReturn => { const theme = useTheme(); const botAvatar = @@ -194,15 +150,6 @@ export const useConversationMessages = ( // Track pending tool calls during streaming const pendingToolCalls = useRef>({}); - const onConversationsUpdateRef = useRef(onConversationsUpdate); - onConversationsUpdateRef.current = onConversationsUpdate; - - const conversationsRef = useRef(conversations); - conversationsRef.current = conversations; - - const setConversationsRef = useRef(setConversations); - setConversationsRef.current = setConversations; - useEffect(() => { if (currentConversation !== conversationId) { setCurrentConversation(conversationId); @@ -323,26 +270,6 @@ export const useConversationMessages = ( ? createTempToolCallsCacheSessionPrefix() : currentConversation; - // Shadow setConversations to relay updates to a module-level listener - // so streaming tokens continue flowing after display mode switches. - const _origSetConversations = setConversationsRef.current; - const _convTracker = { current: conversationsRef.current }; - // eslint-disable-next-line @typescript-eslint/no-shadow - const setConversations: typeof _origSetConversations = ( - updater: Conversations | ((prev: Conversations) => Conversations), - ) => { - const newState = - typeof updater === 'function' - ? updater(_convTracker.current) - : updater; - _convTracker.current = newState; - _origSetConversations(updater); - const msgs = newState[currentConversation] ?? []; - if (msgs.length > 0) { - onConversationsUpdateRef.current?.(msgs, currentConversation); - } - }; - const conversationTuple = [ createUserMessage({ avatar, @@ -401,82 +328,41 @@ export const useConversationMessages = ( buffer += decoder.decode(value, { stream: true }); - // Process all complete messages separated by double newlines - const parts = buffer.split('\n\n'); - buffer = parts.pop()!; - - for (const part of parts) { - const lines = part - .split('\n') - .filter(line => line.startsWith('data:')); + const { + events: parsedEvents, + parseErrors, + remainder, + } = parseSSEBuffer(buffer); + buffer = remainder; + + if (parseErrors.length > 0) { + // eslint-disable-next-line no-console + console.warn('Error parsing JSON:', parseErrors[0]); + if (typeof onComplete === 'function') { + onComplete('Invalid JSON received'); + } + } - const jsonString = lines - .map(line => line.trim().slice(5).trim()) - .join(''); + for (const { event, data } of parsedEvents) { try { - const { event, data } = JSON.parse(jsonString); if (event === 'start') { requestId = data?.request_id; if (currentConversation === TEMP_CONVERSATION_ID) { - // If the conversation is temp, we need to set the new conversation id newConversationId = data?.conversation_id; } onRequestIdReady?.(requestId, newConversationId || undefined); } - // Handle tool_call event if (event === 'tool_call') { - const toolCallData = data?.token; - const legacyObjectCall = - typeof toolCallData === 'object' && - toolCallData !== null && - !Array.isArray(toolCallData) && - (toolCallData as { tool_name?: string }).tool_name; - - const mcpStyle = isMcpStyleToolCallPayload(data); - const rawArgs = data?.args ?? data?.arguments; - const mcpArgs: Record = - rawArgs && - typeof rawArgs === 'object' && - !Array.isArray(rawArgs) - ? rawArgs - : {}; - - let toolCall: ToolCall | undefined; - // Prefer legacy token object when present (backward compatible) - if (legacyObjectCall && data.id !== null) { - toolCall = { - id: data.id, - toolName: (toolCallData as { tool_name: string }) - .tool_name, - arguments: - (toolCallData as { arguments?: Record }) - .arguments || {}, - startTime: Date.now(), - isLoading: true, - }; - } else if (mcpStyle) { - toolCall = { - id: data.id, - toolName: data.name.trim(), - description: - typeof data.type === 'string' && data.type !== data.name - ? data.type - : undefined, - arguments: mcpArgs, - startTime: Date.now(), - isLoading: true, - }; - } + const toolCall = parseToolCallFromEvent(data); if (toolCall && data.id !== null) { const newToolCall: ToolCall = toolCall; pendingToolCalls.current[toolCallIdKey(data.id)] = newToolCall; - // Update the bot message with the pending tool call setConversations(prevConversations => { const conversation = prevConversations[currentConversation] ?? []; @@ -494,7 +380,6 @@ export const useConversationMessages = ( ]; lastMessage.toolCalls = nextToolCalls; - // Cache tool calls for this message (message pair index) const messageIndex = Math.floor(lastMessageIndex / 2); const cacheKey = `${toolCallsCacheKeyPrefix}-${messageIndex}`; setSharedToolCallsCache(cacheKey, nextToolCalls); @@ -510,7 +395,6 @@ export const useConversationMessages = ( }; }); - // Also update streaming ref const [humanMessage, aiMessage] = streamingConversations.current[currentConversation] || []; if (aiMessage) { @@ -531,52 +415,13 @@ export const useConversationMessages = ( } } - // Handle tool_result event if (event === 'tool_result') { - const tokenResult = data?.token; - const legacyResult = isLegacyToolResultToken(tokenResult); - - const mcpHasContent = - data?.id !== null && - data.content !== undefined && - !legacyResult; - - let responsePayload: string | undefined; - let matchToolName: string | undefined; - let toolIdKey: string | undefined; - - if (legacyResult) { - responsePayload = legacyToolResultToString( - tokenResult.response, - ); - matchToolName = tokenResult.tool_name; - toolIdKey = - data?.id !== null ? toolCallIdKey(data.id) : undefined; - } else if (mcpHasContent) { - toolIdKey = toolCallIdKey(data.id); - responsePayload = - typeof data.content === 'string' - ? data.content - : JSON.stringify(data.content); - if ( - typeof data.status === 'string' && - data.status !== 'success' - ) { - responsePayload = `[${data.status}] ${responsePayload}`; - } - } + const result = parseToolResultFromEvent( + data, + pendingToolCalls.current, + ); - if ( - responsePayload !== undefined && - toolIdKey !== undefined - ) { - const pendingCall = pendingToolCalls.current[toolIdKey]; - const endTime = Date.now(); - const executionTime = pendingCall - ? (endTime - pendingCall.startTime) / 1000 - : 0; - - // Update the tool call with result + if (result) { setConversations(prevConversations => { const conversation = prevConversations[currentConversation] ?? []; @@ -585,29 +430,13 @@ export const useConversationMessages = ( if (lastMessageIndex < 0) return prevConversations; const lastMessage = { ...conversation[lastMessageIndex] }; - const toolCalls = lastMessage.toolCalls || []; - - // Find and update the matching tool call - const updatedToolCalls = toolCalls.map(tc => { - const idMatches = - toolCallIdKey(tc.id) === toolIdKey || - (matchToolName !== undefined && - tc.toolName === matchToolName); - if (idMatches) { - return { - ...tc, - response: responsePayload, - endTime, - executionTime, - isLoading: false, - }; - } - return tc; - }); + const updatedToolCalls = applyToolResultToToolCalls( + lastMessage.toolCalls || [], + result, + ); lastMessage.toolCalls = updatedToolCalls; - // Update cache with completed tool call const messageIndex = Math.floor(lastMessageIndex / 2); const cacheKey = `${toolCallsCacheKeyPrefix}-${messageIndex}`; setSharedToolCallsCache(cacheKey, updatedToolCalls); @@ -623,35 +452,20 @@ export const useConversationMessages = ( }; }); - // Also update streaming ref const [humanMessage, aiMessage] = streamingConversations.current[currentConversation] || []; if (aiMessage) { - const toolCalls = aiMessage.toolCalls || []; - const updatedToolCalls = toolCalls.map(tc => { - const idMatches = - toolCallIdKey(tc.id) === toolIdKey || - (matchToolName !== undefined && - tc.toolName === matchToolName); - if (idMatches) { - return { - ...tc, - response: responsePayload, - endTime, - executionTime, - isLoading: false, - }; - } - return tc; - }); + const updatedToolCalls = applyToolResultToToolCalls( + aiMessage.toolCalls || [], + result, + ); streamingConversations.current[currentConversation] = [ humanMessage, { ...aiMessage, toolCalls: updatedToolCalls }, ]; } - // Clean up pending tool call - delete pendingToolCalls.current[toolIdKey]; + delete pendingToolCalls.current[result.toolIdKey]; } } 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 c6f0cc1dab7..2218c1f4912 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/hooks/useLightspeedProviderState.ts @@ -87,10 +87,14 @@ export function useLightspeedProviderState(): { const lightspeedPathnamePrevRef = useRef(null); const isLightspeedRouteRef = useRef(false); + const isOnNotebooksPathRef = useRef(false); const persistedDisplayModeRef = useRef(persistedDisplayMode); const isLightspeedRoute = location.pathname.startsWith(LIGHTSPEED_PATH); isLightspeedRouteRef.current = isLightspeedRoute; + isOnNotebooksPathRef.current = location.pathname.startsWith( + `${LIGHTSPEED_PATH}/notebooks`, + ); persistedDisplayModeRef.current = persistedDisplayMode; const conversationMatch = useMatch( `${LIGHTSPEED_PATH}/conversation/:conversationId`, @@ -261,9 +265,12 @@ export function useLightspeedProviderState(): { setCurrentConversationIdState(id); // Refs: first-stream completion calls onStart after unmount / mode change; a stale // embedded + /lightspeed closure would navigate back to fullscreen without this. + // Skip navigation when the user is on a notebooks path — conversation route updates + // must not overwrite the notebooks URL (fixes notebook closing on mode switch). if ( persistedDisplayModeRef.current === ChatbotDisplayMode.embedded && - isLightspeedRouteRef.current + isLightspeedRouteRef.current && + !isOnNotebooksPathRef.current ) { navigate(lightspeedRoutePath(id), { replace: true }); } diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/stream-event-helpers.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/stream-event-helpers.ts new file mode 100644 index 00000000000..07143a92fcc --- /dev/null +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/utils/stream-event-helpers.ts @@ -0,0 +1,215 @@ +/* + * 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 { ToolCall } from '../types'; + +export const toolCallIdKey = (id: string | number): string => String(id); + +export const normalizeToolCalls = ( + calls: (ToolCall | undefined)[] | undefined, +): ToolCall[] => (calls ?? []).filter((tc): tc is ToolCall => tc !== null); + +export const isMcpStyleToolCallPayload = ( + data: Record | undefined, +): boolean => + !!data && + typeof data.name === 'string' && + data.name.trim().length > 0 && + data.id !== null; + +/** Legacy tool_result uses data.token with at least tool_name and response. */ +export const isLegacyToolResultToken = ( + token: unknown, +): token is { tool_name: string; response?: unknown } => + !!token && + typeof token === 'object' && + !Array.isArray(token) && + typeof (token as { tool_name?: string }).tool_name === 'string' && + (token as { tool_name: string }).tool_name.length > 0; + +export const legacyToolResultToString = (response: unknown): string => { + if (!response) return ''; + if (typeof response === 'string') return response; + try { + return JSON.stringify(response); + } catch { + return String(response); + } +}; + +let tempToolCallsCachePrefixFallbackSeq = 0; + +/** Unique prefix per temp send so late streams cannot migrate another session's tool cache. */ +export function createTempToolCallsCacheSessionPrefix(): string { + const suffix = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${++tempToolCallsCachePrefixFallbackSeq}`; + return `lightspeed-temp:${suffix}`; +} + +/** + * Parse an SSE buffer into discrete JSON events, returning the unparsed + * remainder (incomplete trailing chunk). + */ +export function parseSSEBuffer(buffer: string): { + events: Array<{ event: string; data: any }>; + parseErrors: unknown[]; + remainder: string; +} { + const parts = buffer.split('\n\n'); + const remainder = parts.pop()!; + const events: Array<{ event: string; data: any }> = []; + const parseErrors: unknown[] = []; + + for (const part of parts) { + const lines = part.split('\n').filter(line => line.startsWith('data:')); + const jsonString = lines.map(line => line.trim().slice(5).trim()).join(''); + try { + events.push(JSON.parse(jsonString)); + } catch (error) { + parseErrors.push(error); + } + } + + return { events, parseErrors, remainder }; +} + +/** + * Parse a `tool_call` SSE event payload into a ToolCall object. + * Returns undefined if the payload doesn't match any known format. + */ +export function parseToolCallFromEvent( + data: Record, +): ToolCall | undefined { + const toolCallData = data?.token; + const legacyObjectCall = + typeof toolCallData === 'object' && + toolCallData !== null && + !Array.isArray(toolCallData) && + (toolCallData as { tool_name?: string }).tool_name; + + const mcpStyle = isMcpStyleToolCallPayload(data); + const rawArgs = data?.args ?? data?.arguments; + const mcpArgs: Record = + rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) + ? rawArgs + : {}; + + if (legacyObjectCall && data.id !== null) { + return { + id: data.id, + toolName: (toolCallData as { tool_name: string }).tool_name, + arguments: + (toolCallData as { arguments?: Record }).arguments || {}, + startTime: Date.now(), + isLoading: true, + }; + } + + if (mcpStyle) { + return { + id: data.id, + toolName: data.name.trim(), + description: + typeof data.type === 'string' && data.type !== data.name + ? data.type + : undefined, + arguments: mcpArgs, + startTime: Date.now(), + isLoading: true, + }; + } + + return undefined; +} + +export interface ToolResultParsed { + responsePayload: string; + matchToolName: string | undefined; + toolIdKey: string; + endTime: number; + executionTime: number; +} + +/** + * Parse a `tool_result` SSE event payload and compute execution time. + * Returns undefined if the payload doesn't match any known format. + */ +export function parseToolResultFromEvent( + data: Record, + pendingToolCalls: Record, +): ToolResultParsed | undefined { + const tokenResult = data?.token; + const legacyResult = isLegacyToolResultToken(tokenResult); + const mcpHasContent = + data?.id !== null && data.content !== undefined && !legacyResult; + + let responsePayload: string | undefined; + let matchToolName: string | undefined; + let toolIdKey: string | undefined; + + if (legacyResult) { + responsePayload = legacyToolResultToString(tokenResult.response); + matchToolName = tokenResult.tool_name; + toolIdKey = data?.id !== null ? toolCallIdKey(data.id) : undefined; + } else if (mcpHasContent) { + toolIdKey = toolCallIdKey(data.id); + responsePayload = + typeof data.content === 'string' + ? data.content + : JSON.stringify(data.content); + if (typeof data.status === 'string' && data.status !== 'success') { + responsePayload = `[${data.status}] ${responsePayload}`; + } + } + + if (responsePayload === undefined || toolIdKey === undefined) { + return undefined; + } + + const pendingCall = pendingToolCalls[toolIdKey]; + const endTime = Date.now(); + const executionTime = pendingCall + ? (endTime - pendingCall.startTime) / 1000 + : 0; + + return { responsePayload, matchToolName, toolIdKey, endTime, executionTime }; +} + +/** + * Apply a tool result to a list of tool calls, returning the updated array. + */ +export function applyToolResultToToolCalls( + toolCalls: ToolCall[], + result: ToolResultParsed, +): ToolCall[] { + return toolCalls.map(tc => { + const idMatches = + toolCallIdKey(tc.id) === result.toolIdKey || + (result.matchToolName !== undefined && + tc.toolName === result.matchToolName); + if (idMatches) { + return { + ...tc, + response: result.responsePayload, + endTime: result.endTime, + executionTime: result.executionTime, + isLoading: false, + }; + } + return tc; + }); +} From ca1531ea53662e501281d4bb282eb3f6f37e90cd Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 27 Aug 2026 01:36:27 +0530 Subject: [PATCH 21/23] fix(intelligent-assistant): stream notebook source citations during response Emit an `end` SSE event carrying `referenced_documents` from the notebook backend's `response.completed` handler so the sources chip appears live during streaming instead of only after a page refresh. extractReferencedDocuments maps Responses API file_search results and file_citation annotations into the legacy referenced_documents shape the frontend already consumes in notebookStreamStore. Assisted-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 --- .../notebook-overlay-docked-modes.md | 1 + .../src/service/notebooks/notebooksRouters.ts | 115 ++++++++++++++++-- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md index d8366e1ec0c..2849f7b5cf4 100644 --- a/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md +++ b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md @@ -1,5 +1,6 @@ --- '@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor +'@red-hat-developer-hub/backstage-plugin-intelligent-assistant-backend': minor --- implement docked and overlay display modes for Notebook diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts index 0f4430cb7d4..b5956686f71 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts @@ -69,6 +69,104 @@ export interface NotebooksRouterOptions { permissions: PermissionsService; } +/** + * A citation/source in the legacy `referenced_documents` shape expected by the + * frontend (see `transformDocumentsToSources` in the intelligent-assistant + * plugin). Only `doc_title` and `doc_url` are consumed; `doc_description` is + * optional. + */ +interface ReferencedDocument { + doc_title: string; + doc_url: string; + doc_description?: string; +} + +/** + * Extracts referenced documents (file_search citations) from a Responses API + * `response.completed` payload so they can be relayed to the frontend as a + * legacy `end` event. + * + * The Responses API surfaces file_search results in two places: + * - a `file_search_call` output item carrying the matched `results` + * (each with `file_id`, `filename`, `text`, and `attributes`), and + * - `file_citation` annotations on the assistant `message` output text. + * + * We prefer the set of files actually cited via annotations; if the model + * produced no annotations we fall back to all file_search results so the + * sources chip is still populated. Documents are de-duplicated by file id + * (falling back to filename/title). + */ +const extractReferencedDocuments = (response: any): ReferencedDocument[] => { + const output: any[] = Array.isArray(response?.output) ? response.output : []; + + // Map of file_id -> file_search result metadata. + const resultsByFileId = new Map(); + for (const item of output) { + if (item?.type !== 'file_search_call') continue; + const results: any[] = Array.isArray(item?.results) ? item.results : []; + for (const result of results) { + const fileId = result?.file_id ?? result?.id; + if (fileId) resultsByFileId.set(fileId, result); + } + } + + // File ids/filenames actually cited in the assistant message text. + const citedFileIds = new Set(); + for (const item of output) { + if (item?.type !== 'message') continue; + const content: any[] = Array.isArray(item?.content) ? item.content : []; + for (const part of content) { + const annotations: any[] = Array.isArray(part?.annotations) + ? part.annotations + : []; + for (const annotation of annotations) { + if (annotation?.type !== 'file_citation') continue; + const fileId = annotation?.file_id ?? annotation?.filename; + if (fileId) citedFileIds.add(fileId); + } + } + } + + const toReferencedDocument = ( + fileId: string, + result: any, + ): ReferencedDocument => { + const attributes = result?.attributes ?? {}; + return { + doc_title: + attributes?.title ?? + attributes?.doc_title ?? + result?.filename ?? + fileId, + doc_url: attributes?.url ?? attributes?.doc_url ?? result?.doc_url ?? '', + doc_description: + attributes?.description ?? attributes?.doc_description ?? result?.text, + }; + }; + + const documents: ReferencedDocument[] = []; + const seen = new Set(); + const pushDoc = (fileId: string, result: any) => { + const doc = toReferencedDocument(fileId, result); + const key = fileId || doc.doc_url || doc.doc_title; + if (!key || seen.has(key)) return; + seen.add(key); + documents.push(doc); + }; + + if (citedFileIds.size > 0) { + for (const fileId of citedFileIds) { + pushDoc(fileId, resultsByFileId.get(fileId)); + } + } else { + for (const [fileId, result] of resultsByFileId) { + pushDoc(fileId, result); + } + } + + return documents; +}; + export async function createNotebooksRouter( options: NotebooksRouterOptions, ): Promise { @@ -235,14 +333,17 @@ export async function createNotebooksRouter( }; this.push(`data: ${JSON.stringify(legacy)}\n\n`); } else if (eventType === 'response.completed') { - // Log the full response to see what we're getting - logger.info( - `Full response.completed event: ${JSON.stringify(parsed?.response, null, 2)}`, - ); - // Extract citations/sources from tool calls (file_search results) - - // this.push(`data: ${JSON.stringify(legacy)}\n\n`); + // and relay them as a legacy `end` event so the frontend can + // populate the sources chip while streaming (without a refresh). + const referenced_documents = extractReferencedDocuments( + parsed?.response, + ); + const legacy = { + event: 'end', + data: { referenced_documents }, + }; + this.push(`data: ${JSON.stringify(legacy)}\n\n`); } else { // Log unhandled event types to help identify what we're missing logger.debug(`Unhandled SSE event type: ${eventType}`, parsed); From ca5e3f432f404b1b0558b4f5b0d2c31d0fa1bd09 Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 27 Aug 2026 02:48:22 +0530 Subject: [PATCH 22/23] fix(intelligent-assistant): don't use raw match text as notebook source description Drop the `result.text` fallback in extractReferencedDocuments so the sources chip no longer shows the raw matched document chunk under the source name. Assisted-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 --- .../src/service/notebooks/notebooksRouters.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts index b5956686f71..1b317812dea 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts @@ -139,8 +139,7 @@ const extractReferencedDocuments = (response: any): ReferencedDocument[] => { result?.filename ?? fileId, doc_url: attributes?.url ?? attributes?.doc_url ?? result?.doc_url ?? '', - doc_description: - attributes?.description ?? attributes?.doc_description ?? result?.text, + doc_description: attributes?.description ?? attributes?.doc_description, }; }; From a6d33e28c26837d0383eefe13abd6909bc09cc3a Mon Sep 17 00:00:00 2001 From: rohitratannagar Date: Thu, 27 Aug 2026 02:48:35 +0530 Subject: [PATCH 23/23] fix(intelligent-assistant): restore toast inset and max-width styling Re-add the toast inset, max-width, and per-alert styles that were dropped when the toast was extracted into ToastAlertGroup, so it renders as a compact top-right toast instead of stretching full width. Assisted-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 --- .../notebook-overlay-docked-modes.md | 1 - .../src/service/notebooks/notebooksRouters.ts | 114 ++---------------- .../src/components/ToastAlertGroup.tsx | 12 +- 3 files changed, 18 insertions(+), 109 deletions(-) diff --git a/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md index 2849f7b5cf4..d8366e1ec0c 100644 --- a/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md +++ b/workspaces/intelligent-assistant/.changeset/notebook-overlay-docked-modes.md @@ -1,6 +1,5 @@ --- '@red-hat-developer-hub/backstage-plugin-intelligent-assistant': minor -'@red-hat-developer-hub/backstage-plugin-intelligent-assistant-backend': minor --- implement docked and overlay display modes for Notebook diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts index 1b317812dea..0f4430cb7d4 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant-backend/src/service/notebooks/notebooksRouters.ts @@ -69,103 +69,6 @@ export interface NotebooksRouterOptions { permissions: PermissionsService; } -/** - * A citation/source in the legacy `referenced_documents` shape expected by the - * frontend (see `transformDocumentsToSources` in the intelligent-assistant - * plugin). Only `doc_title` and `doc_url` are consumed; `doc_description` is - * optional. - */ -interface ReferencedDocument { - doc_title: string; - doc_url: string; - doc_description?: string; -} - -/** - * Extracts referenced documents (file_search citations) from a Responses API - * `response.completed` payload so they can be relayed to the frontend as a - * legacy `end` event. - * - * The Responses API surfaces file_search results in two places: - * - a `file_search_call` output item carrying the matched `results` - * (each with `file_id`, `filename`, `text`, and `attributes`), and - * - `file_citation` annotations on the assistant `message` output text. - * - * We prefer the set of files actually cited via annotations; if the model - * produced no annotations we fall back to all file_search results so the - * sources chip is still populated. Documents are de-duplicated by file id - * (falling back to filename/title). - */ -const extractReferencedDocuments = (response: any): ReferencedDocument[] => { - const output: any[] = Array.isArray(response?.output) ? response.output : []; - - // Map of file_id -> file_search result metadata. - const resultsByFileId = new Map(); - for (const item of output) { - if (item?.type !== 'file_search_call') continue; - const results: any[] = Array.isArray(item?.results) ? item.results : []; - for (const result of results) { - const fileId = result?.file_id ?? result?.id; - if (fileId) resultsByFileId.set(fileId, result); - } - } - - // File ids/filenames actually cited in the assistant message text. - const citedFileIds = new Set(); - for (const item of output) { - if (item?.type !== 'message') continue; - const content: any[] = Array.isArray(item?.content) ? item.content : []; - for (const part of content) { - const annotations: any[] = Array.isArray(part?.annotations) - ? part.annotations - : []; - for (const annotation of annotations) { - if (annotation?.type !== 'file_citation') continue; - const fileId = annotation?.file_id ?? annotation?.filename; - if (fileId) citedFileIds.add(fileId); - } - } - } - - const toReferencedDocument = ( - fileId: string, - result: any, - ): ReferencedDocument => { - const attributes = result?.attributes ?? {}; - return { - doc_title: - attributes?.title ?? - attributes?.doc_title ?? - result?.filename ?? - fileId, - doc_url: attributes?.url ?? attributes?.doc_url ?? result?.doc_url ?? '', - doc_description: attributes?.description ?? attributes?.doc_description, - }; - }; - - const documents: ReferencedDocument[] = []; - const seen = new Set(); - const pushDoc = (fileId: string, result: any) => { - const doc = toReferencedDocument(fileId, result); - const key = fileId || doc.doc_url || doc.doc_title; - if (!key || seen.has(key)) return; - seen.add(key); - documents.push(doc); - }; - - if (citedFileIds.size > 0) { - for (const fileId of citedFileIds) { - pushDoc(fileId, resultsByFileId.get(fileId)); - } - } else { - for (const [fileId, result] of resultsByFileId) { - pushDoc(fileId, result); - } - } - - return documents; -}; - export async function createNotebooksRouter( options: NotebooksRouterOptions, ): Promise { @@ -332,17 +235,14 @@ export async function createNotebooksRouter( }; this.push(`data: ${JSON.stringify(legacy)}\n\n`); } else if (eventType === 'response.completed') { - // Extract citations/sources from tool calls (file_search results) - // and relay them as a legacy `end` event so the frontend can - // populate the sources chip while streaming (without a refresh). - const referenced_documents = extractReferencedDocuments( - parsed?.response, + // Log the full response to see what we're getting + logger.info( + `Full response.completed event: ${JSON.stringify(parsed?.response, null, 2)}`, ); - const legacy = { - event: 'end', - data: { referenced_documents }, - }; - this.push(`data: ${JSON.stringify(legacy)}\n\n`); + + // Extract citations/sources from tool calls (file_search results) + + // this.push(`data: ${JSON.stringify(legacy)}\n\n`); } else { // Log unhandled event types to help identify what we're missing logger.debug(`Unhandled SSE event type: ${eventType}`, parsed); diff --git a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx index 0a16cead0a1..1421da0c3ea 100644 --- a/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx +++ b/workspaces/intelligent-assistant/plugins/intelligent-assistant/src/components/ToastAlertGroup.tsx @@ -25,10 +25,19 @@ import { type AlertProps, } from '@patternfly/react-core'; -const useStyles = makeStyles(() => ({ +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 = { @@ -56,6 +65,7 @@ export const ToastAlertGroup = ({ key={key} variant={AlertVariant[variant ?? 'success']} title={title} + className={classes.toastAlert} timeout={2000} onTimeout={() => onRemoveAlert(key as React.Key)} actionClose={