Skip to content

Commit ad1528c

Browse files
remember last used model (#1765)
1 parent 71bb80d commit ad1528c

3 files changed

Lines changed: 275 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
3+
---
4+
5+
Add localStorage persistence for last selected model
6+
7+
The Lightspeed plugin now remembers the user's last selected model across page refreshes, automatically restoring it when available.

workspaces/lightspeed/plugins/lightspeed/src/components/LightspeedPage.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const useStyles = makeStyles(() =>
4242

4343
const THEME_DARK = 'dark';
4444
const THEME_DARK_CLASS = 'pf-v6-theme-dark';
45+
const LAST_SELECTED_MODEL_KEY = 'lastSelectedModel';
4546

4647
const LightspeedPageInner = () => {
4748
const classes = useStyles();
@@ -91,11 +92,57 @@ const LightspeedPageInner = () => {
9192

9293
useEffect(() => {
9394
if (modelsItems.length > 0) {
94-
setSelectedModel(modelsItems[0].value);
95-
setSelectedProvider(modelsItems[0].provider);
95+
try {
96+
const storedData = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
97+
const parsedData = storedData ? JSON.parse(storedData) : null;
98+
99+
// Check if stored model exists in available models
100+
const storedModel = parsedData?.model
101+
? modelsItems.find(m => m.value === parsedData.model)
102+
: null;
103+
104+
if (storedModel) {
105+
setSelectedModel(storedModel.value);
106+
setSelectedProvider(storedModel.provider);
107+
} else {
108+
// Fallback to first model if stored model is not available
109+
setSelectedModel(modelsItems[0].value);
110+
setSelectedProvider(modelsItems[0].provider);
111+
}
112+
} catch (error) {
113+
// eslint-disable-next-line no-console
114+
console.error(
115+
'Error loading last selected model from localStorage:',
116+
error,
117+
);
118+
// Fallback to first model on error
119+
setSelectedModel(modelsItems[0].value);
120+
setSelectedProvider(modelsItems[0].provider);
121+
}
96122
}
97123
}, [modelsItems]);
98124

125+
// Save to localStorage whenever model or provider changes
126+
useEffect(() => {
127+
if (selectedModel && selectedProvider) {
128+
try {
129+
localStorage.setItem(
130+
LAST_SELECTED_MODEL_KEY,
131+
JSON.stringify({
132+
model: selectedModel,
133+
provider: selectedProvider,
134+
}),
135+
);
136+
} catch (error) {
137+
// eslint-disable-next-line no-console
138+
console.error(
139+
'Error saving last selected model to localStorage:',
140+
error,
141+
);
142+
}
143+
}
144+
}, [selectedModel, selectedProvider]);
145+
99146
if (loading) {
100147
return null;
101148
}

workspaces/lightspeed/plugins/lightspeed/src/components/__tests__/LightspeedPage.test.tsx

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ const mockUsePermission = usePermission as jest.MockedFunction<
7575
>;
7676

7777
describe('LightspeedPage', () => {
78+
beforeEach(() => {
79+
localStorage.clear();
80+
});
81+
7882
it('should not display chatbot if permission checks are in loading phase', async () => {
7983
mockUsePermission.mockReturnValue({ loading: true, allowed: true });
8084

@@ -153,4 +157,219 @@ describe('LightspeedPage', () => {
153157
"You'll no longer see this chat here. This will also delete related activity like prompts, responses, and feedback from your Lightspeed Activity.",
154158
);
155159
});
160+
161+
describe('localStorage model persistence', () => {
162+
const LAST_SELECTED_MODEL_KEY = 'lastSelectedModel';
163+
const mockModels = [
164+
{
165+
provider_resource_id: 'model-1',
166+
provider_id: 'provider-1',
167+
model_type: 'llm',
168+
},
169+
{
170+
provider_resource_id: 'model-2',
171+
provider_id: 'provider-2',
172+
model_type: 'llm',
173+
},
174+
{
175+
provider_resource_id: 'model-3',
176+
provider_id: 'provider-3',
177+
model_type: 'llm',
178+
},
179+
];
180+
181+
beforeEach(() => {
182+
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
183+
const { useAllModels } = require('../../hooks/useAllModels');
184+
(useAllModels as jest.Mock).mockReturnValue({
185+
data: mockModels,
186+
isLoading: false,
187+
isError: false,
188+
error: null,
189+
});
190+
});
191+
192+
it('should load last selected model from localStorage on mount', async () => {
193+
const storedData = JSON.stringify({
194+
model: 'model-2',
195+
provider: 'provider-2',
196+
});
197+
localStorage.setItem(LAST_SELECTED_MODEL_KEY, storedData);
198+
199+
await renderInTestApp(
200+
<TestApiProvider
201+
apis={[
202+
[identityApiRef, identityApi],
203+
[lightspeedApiRef, mockLightspeedApi],
204+
]}
205+
>
206+
<LightspeedPage />
207+
</TestApiProvider>,
208+
);
209+
210+
await waitFor(() => {
211+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
212+
});
213+
214+
// Verify the stored model was loaded
215+
const storedValue = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
216+
expect(storedValue).toBe(storedData);
217+
});
218+
219+
it('should fallback to first model if stored model does not exist', async () => {
220+
const storedData = JSON.stringify({
221+
model: 'non-existent-model',
222+
provider: 'non-existent-provider',
223+
});
224+
localStorage.setItem(LAST_SELECTED_MODEL_KEY, storedData);
225+
226+
await renderInTestApp(
227+
<TestApiProvider
228+
apis={[
229+
[identityApiRef, identityApi],
230+
[lightspeedApiRef, mockLightspeedApi],
231+
]}
232+
>
233+
<LightspeedPage />
234+
</TestApiProvider>,
235+
);
236+
237+
await waitFor(() => {
238+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
239+
});
240+
241+
// Should fallback to first model and save it
242+
await waitFor(() => {
243+
const storedValue = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
244+
const parsed = storedValue ? JSON.parse(storedValue) : null;
245+
expect(parsed?.model).toBe('model-1');
246+
expect(parsed?.provider).toBe('provider-1');
247+
});
248+
});
249+
250+
it('should save model to localStorage when model is selected', async () => {
251+
await renderInTestApp(
252+
<TestApiProvider
253+
apis={[
254+
[identityApiRef, identityApi],
255+
[lightspeedApiRef, mockLightspeedApi],
256+
]}
257+
>
258+
<LightspeedPage />
259+
</TestApiProvider>,
260+
);
261+
262+
await waitFor(() => {
263+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
264+
});
265+
266+
// Should save first model by default
267+
await waitFor(() => {
268+
const storedValue = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
269+
const parsed = storedValue ? JSON.parse(storedValue) : null;
270+
expect(parsed?.model).toBe('model-1');
271+
expect(parsed?.provider).toBe('provider-1');
272+
});
273+
});
274+
275+
it('should handle localStorage errors gracefully when loading', async () => {
276+
// Mock localStorage.getItem to throw an error for the specific key
277+
const originalGetItem = localStorage.getItem;
278+
const getItemSpy = jest.fn((key: string) => {
279+
if (key === LAST_SELECTED_MODEL_KEY) {
280+
throw new Error('localStorage error');
281+
}
282+
return originalGetItem.call(localStorage, key);
283+
});
284+
localStorage.getItem = getItemSpy;
285+
286+
await renderInTestApp(
287+
<TestApiProvider
288+
apis={[
289+
[identityApiRef, identityApi],
290+
[lightspeedApiRef, mockLightspeedApi],
291+
]}
292+
>
293+
<LightspeedPage />
294+
</TestApiProvider>,
295+
);
296+
297+
await waitFor(() => {
298+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
299+
});
300+
301+
// Restore before checking localStorage
302+
localStorage.getItem = originalGetItem;
303+
304+
// Should still render and fallback to first model (saved after error)
305+
// This verifies that the component handles the error gracefully
306+
await waitFor(() => {
307+
const storedValue = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
308+
const parsed = storedValue ? JSON.parse(storedValue) : null;
309+
expect(parsed?.model).toBe('model-1');
310+
expect(parsed?.provider).toBe('provider-1');
311+
});
312+
});
313+
314+
it('should handle localStorage errors gracefully when saving', async () => {
315+
// Mock localStorage.setItem to throw an error for the specific key
316+
const originalSetItem = localStorage.setItem;
317+
const setItemSpy = jest.fn((key: string, value: string) => {
318+
if (key === LAST_SELECTED_MODEL_KEY) {
319+
throw new Error('localStorage setItem error');
320+
}
321+
return originalSetItem.call(localStorage, key, value);
322+
});
323+
localStorage.setItem = setItemSpy;
324+
325+
await renderInTestApp(
326+
<TestApiProvider
327+
apis={[
328+
[identityApiRef, identityApi],
329+
[lightspeedApiRef, mockLightspeedApi],
330+
]}
331+
>
332+
<LightspeedPage />
333+
</TestApiProvider>,
334+
);
335+
336+
await waitFor(() => {
337+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
338+
});
339+
340+
// Should still render despite save error
341+
// This verifies that the component handles the error gracefully
342+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
343+
344+
// Restore
345+
localStorage.setItem = originalSetItem;
346+
});
347+
348+
it('should use first model when localStorage is empty', async () => {
349+
localStorage.removeItem(LAST_SELECTED_MODEL_KEY);
350+
351+
await renderInTestApp(
352+
<TestApiProvider
353+
apis={[
354+
[identityApiRef, identityApi],
355+
[lightspeedApiRef, mockLightspeedApi],
356+
]}
357+
>
358+
<LightspeedPage />
359+
</TestApiProvider>,
360+
);
361+
362+
await waitFor(() => {
363+
expect(screen.getByText('LightspeedChat')).toBeInTheDocument();
364+
});
365+
366+
// Should save first model
367+
await waitFor(() => {
368+
const storedValue = localStorage.getItem(LAST_SELECTED_MODEL_KEY);
369+
const parsed = storedValue ? JSON.parse(storedValue) : null;
370+
expect(parsed?.model).toBe('model-1');
371+
expect(parsed?.provider).toBe('provider-1');
372+
});
373+
});
374+
});
156375
});

0 commit comments

Comments
 (0)