diff --git a/workspaces/lightspeed/.changeset/eleven-walls-nail.md b/workspaces/lightspeed/.changeset/eleven-walls-nail.md new file mode 100644 index 000000000..cbc244b5c --- /dev/null +++ b/workspaces/lightspeed/.changeset/eleven-walls-nail.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-lightspeed': patch +--- + +Persist last conversation state and reload on user revisit diff --git a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx index 0f8059586..81375f5e8 100644 --- a/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx +++ b/workspaces/lightspeed/plugins/lightspeed/src/components/LightSpeedChat.tsx @@ -41,6 +41,7 @@ import { useConversationMessages } from '../hooks/useConversationMessages'; import { useConversations } from '../hooks/useConversations'; import { useCreateConversation } from '../hooks/useCreateConversation'; import { useDeleteConversation } from '../hooks/useDeleteConversation'; +import { useLastOpenedConversation } from '../hooks/useLastOpenedConversation'; import { useLightspeedDeletePermission } from '../hooks/useLightspeedDeletePermission'; import { ConversationSummary } from '../types'; import { @@ -89,7 +90,7 @@ export const LightspeedChat = ({ const [announcement, setAnnouncement] = React.useState(''); const [conversationId, setConversationId] = React.useState(''); const [isDrawerOpen, setIsDrawerOpen] = React.useState(true); - const [newChatCreated, setNewChatCreated] = React.useState(true); + const [newChatCreated, setNewChatCreated] = React.useState(false); const [isSendButtonDisabled, setIsSendButtonDisabled] = React.useState(false); const [error, setError] = React.useState(null); @@ -97,6 +98,15 @@ export const LightspeedChat = ({ React.useState(''); const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); + const { isReady, lastOpenedId, setLastOpenedId, clearLastOpenedId } = + useLastOpenedConversation(user); + + // Sync conversationId with lastOpenedId whenever lastOpenedId changes + React.useEffect(() => { + if (isReady && lastOpenedId !== null) { + setConversationId(lastOpenedId); + } + }, [lastOpenedId, isReady]); const queryClient = useQueryClient(); @@ -106,10 +116,11 @@ export const LightspeedChat = ({ const { allowed: hasDeleteAccess } = useLightspeedDeletePermission(); React.useEffect(() => { - if (user) { + if (user && lastOpenedId === null && isReady) { createConversation() .then(({ conversation_id }) => { setConversationId(conversation_id); + setNewChatCreated(true); }) .catch(e => { // eslint-disable-next-line @@ -117,7 +128,14 @@ export const LightspeedChat = ({ setError(e); }); } - }, [user, setConversationId, createConversation]); + }, [user, isReady, lastOpenedId, setConversationId, createConversation]); + + React.useEffect(() => { + // Update last opened conversation whenever `conversationId` changes + if (conversationId) { + setLastOpenedId(conversationId); + } + }, [conversationId, setLastOpenedId]); const onComplete = (message: string) => { setIsSendButtonDisabled(false); @@ -177,14 +195,23 @@ export const LightspeedChat = ({ conversation_id: targetConversationId, invalidateCache: false, }); - onNewChat(); + if (targetConversationId === lastOpenedId) { + onNewChat(); + clearLastOpenedId(); + } setIsDeleteModalOpen(false); } catch (e) { // eslint-disable-next-line no-console console.warn(e); } })(); - }, [deleteConversation, onNewChat, targetConversationId]); + }, [ + deleteConversation, + clearLastOpenedId, + lastOpenedId, + onNewChat, + targetConversationId, + ]); const additionalMessageProps = React.useCallback( (conversationSummary: ConversationSummary) => ({ @@ -245,20 +272,25 @@ export const LightspeedChat = ({ [setConversationId], ); - const welcomePrompts = newChatCreated - ? [ - { - title: 'Topic 1', - message: 'Helpful prompt for Topic 1', - onClick: () => sendMessage('Helpful prompt for Topic 1'), - }, - { - title: 'Topic 2', - message: 'Helpful prompt for Topic 2', - onClick: () => sendMessage('Helpful prompt for Topic 2'), - }, - ] - : []; + const conversationFound = !!conversations.find( + c => c.conversation_id === conversationId, + ); + + const welcomePrompts = + newChatCreated || (!conversationFound && conversationMessages.length === 0) + ? [ + { + title: 'Topic 1', + message: 'Helpful prompt for Topic 1', + onClick: () => sendMessage('Helpful prompt for Topic 1'), + }, + { + title: 'Topic 2', + message: 'Helpful prompt for Topic 2', + onClick: () => sendMessage('Helpful prompt for Topic 2'), + }, + ] + : []; const handleFilter = React.useCallback((value: string) => { setFilterValue(value); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/uselastOpenedConversation.test.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/uselastOpenedConversation.test.ts new file mode 100644 index 000000000..96eccc643 --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/__tests__/uselastOpenedConversation.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { act, renderHook } from '@testing-library/react'; + +import { useLastOpenedConversation } from '../useLastOpenedConversation'; + +describe('useLastOpenedConversation', () => { + const localStorageKey = 'lastOpenedConversation'; + const mockUser = 'user123'; + + beforeEach(() => { + localStorage.clear(); + }); + + it('should not be ready when user is undefined', () => { + const { result } = renderHook(() => useLastOpenedConversation(undefined)); + + expect(result.current.isReady).toBe(false); + expect(result.current.lastOpenedId).toBeNull(); + }); + + it('should load the lastOpenedId from localStorage when user is provided', () => { + const storedData = JSON.stringify({ [mockUser]: 'conv456' }); + localStorage.setItem(localStorageKey, storedData); + + const { result } = renderHook(() => useLastOpenedConversation(mockUser)); + + expect(result.current.isReady).toBe(true); + expect(result.current.lastOpenedId).toBe('conv456'); + }); + + it('should handle missing user data in localStorage', () => { + localStorage.setItem(localStorageKey, JSON.stringify({})); + + const { result } = renderHook(() => useLastOpenedConversation(mockUser)); + + expect(result.current.isReady).toBe(true); + expect(result.current.lastOpenedId).toBeNull(); + }); + + it('should update localStorage when setLastOpenedId is called', () => { + const { result } = renderHook(() => useLastOpenedConversation(mockUser)); + + act(() => { + result.current.setLastOpenedId('conv789'); + }); + + const storedData = JSON.parse( + localStorage.getItem(localStorageKey) || '{}', + ); + expect(storedData[mockUser]).toBe('conv789'); + expect(result.current.lastOpenedId).toBe('conv789'); + }); + + it('should clear lastOpenedId when clearLastOpenedId is called', () => { + localStorage.setItem( + localStorageKey, + JSON.stringify({ [mockUser]: 'conv456' }), + ); + + const { result } = renderHook(() => useLastOpenedConversation(mockUser)); + + act(() => { + result.current.clearLastOpenedId(); + }); + + const storedData = JSON.parse( + localStorage.getItem(localStorageKey) || '{}', + ); + expect(storedData[mockUser]).toBeUndefined(); + expect(result.current.lastOpenedId).toBeNull(); + }); + + it('should not update localStorage if user is undefined', () => { + const { result } = renderHook(() => useLastOpenedConversation(undefined)); + + act(() => { + result.current.setLastOpenedId('conv123'); + }); + + expect(localStorage.getItem(localStorageKey)).toBeNull(); + }); + + it('should not clear localStorage if user is undefined', () => { + const storedData = JSON.stringify({ [mockUser]: 'conv456' }); + localStorage.setItem(localStorageKey, storedData); + }); +}); diff --git a/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLastOpenedConversation.ts b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLastOpenedConversation.ts new file mode 100644 index 000000000..795bf800e --- /dev/null +++ b/workspaces/lightspeed/plugins/lightspeed/src/hooks/useLastOpenedConversation.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react'; + +type UseLastOpenedConversationReturn = { + lastOpenedId: string | null; + setLastOpenedId: Dispatch>; + clearLastOpenedId: () => void; + isReady: boolean; +}; + +export const useLastOpenedConversation = ( + user: string | undefined, + key = 'lastOpenedConversation', +): UseLastOpenedConversationReturn => { + const [lastOpenedId, setLastOpenedId] = useState(null); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + if (!user) { + setLastOpenedId(null); + setIsReady(false); + return; + } + + try { + const storedData = localStorage.getItem(key); + const parsedData = storedData ? JSON.parse(storedData) : {}; + setLastOpenedId(parsedData[user] || null); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error accessing localStorage:', error); + } finally { + setIsReady(true); + } + }, [user, key]); + + useEffect(() => { + if (!user || lastOpenedId === null) return; + // Update localStorage whenever the last opened ID changes + try { + const storedData = localStorage.getItem(key); + const parsedData = storedData ? JSON.parse(storedData) : {}; + + if (parsedData[user] !== lastOpenedId) { + parsedData[user] = lastOpenedId; + localStorage.setItem(key, JSON.stringify(parsedData)); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error updating localStorage:', error); + } + }, [lastOpenedId, user, key]); + + const clearLastOpenedId = useCallback(() => { + if (!user) return; + + try { + const storedData = localStorage.getItem(key); + const parsedData = storedData ? JSON.parse(storedData) : {}; + delete parsedData[user]; + localStorage.setItem(key, JSON.stringify(parsedData)); + setLastOpenedId(null); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error clearing localStorage:', error); + } + }, [user, key]); + + return { lastOpenedId, setLastOpenedId, clearLastOpenedId, isReady }; +};