Skip to content

Commit

Permalink
fix(lightspeed): rememeber last opened conversation and display it on…
Browse files Browse the repository at this point in the history
… load (#159)
  • Loading branch information
karthikjeeyar authored Jan 8, 2025
1 parent 7b21401 commit 9556c86
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 19 deletions.
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/eleven-walls-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
---

Persist last conversation state and reload on user revisit
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -89,14 +90,23 @@ export const LightspeedChat = ({
const [announcement, setAnnouncement] = React.useState<string>('');
const [conversationId, setConversationId] = React.useState<string>('');
const [isDrawerOpen, setIsDrawerOpen] = React.useState<boolean>(true);
const [newChatCreated, setNewChatCreated] = React.useState<boolean>(true);
const [newChatCreated, setNewChatCreated] = React.useState<boolean>(false);
const [isSendButtonDisabled, setIsSendButtonDisabled] =
React.useState<boolean>(false);
const [error, setError] = React.useState<Error | null>(null);
const [targetConversationId, setTargetConversationId] =
React.useState<string>('');
const [isDeleteModalOpen, setIsDeleteModalOpen] =
React.useState<boolean>(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();

Expand All @@ -106,18 +116,26 @@ 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
console.warn(e);
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);
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<string | null>>;
clearLastOpenedId: () => void;
isReady: boolean;
};

export const useLastOpenedConversation = (
user: string | undefined,
key = 'lastOpenedConversation',
): UseLastOpenedConversationReturn => {
const [lastOpenedId, setLastOpenedId] = useState<string | null>(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 };
};

0 comments on commit 9556c86

Please sign in to comment.