feat: check the server on launch and remember the selected model

The chat showed 'Server offline' until the user opened Settings, and the selected model was forgotten on every restart. useAutoConnect fetches the models once after the persisted settings are restored; activeModelId is now persisted.
This commit is contained in:
evandro committed 2026-10-10 04:31:21 -03:00
1 parent 1a3ce9ccec
commit 541aefd7e4
5 files changed
+72 -2

No files matched your search

+4 -2
View File
@@ -162,13 +162,14 @@ describe('chat sessions', () => {
});
describe('persistence', () => {
it('persists settings and chats but not transient connection or streaming state', async () => {
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
useAppStore.setState({
theme: 'dark',
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
isConnected: true,
isStreaming: true,
activeModelId: 'model',
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
editingMessageId: 'e',
});
state().addMessage({ role: 'user', content: 'persist me' });
@@ -178,9 +179,10 @@ describe('persistence', () => {
const persisted = JSON.parse(raw as string).state;
expect(Object.keys(persisted).sort()).toEqual(
['chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
);
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
expect(persisted.activeModelId).toBe('model');
expect(persisted.messages[0].content).toBe('persist me');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { act, renderHook } from '@testing-library/react-native';
import { useAutoConnect } from '../hooks/useAutoConnect';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('useAutoConnect', () => {
it('loads the models once on mount and marks the server connected', async () => {
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
renderHook(() => useAutoConnect());
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().isConnected).toBe(true);
expect(useAppStore.getState().activeModelId).toBe('m1');
});
it('leaves the app offline when the server does not answer', async () => {
mockApi.getModels.mockRejectedValue(new Error('down'));
renderHook(() => useAutoConnect());
await act(async () => {});
expect(useAppStore.getState().isConnected).toBe(false);
});
it('does not check again on re-render', async () => {
mockApi.getModels.mockResolvedValue([]);
const { rerender } = renderHook(() => useAutoConnect());
await act(async () => {});
rerender({});
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -1,4 +1,5 @@
import { ModelSelector } from '@/components/chat/ModelSelector';
import { useAutoConnect } from '@/hooks/useAutoConnect';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router';
@@ -10,6 +11,7 @@ export default function ChatLayout() {
const router = useRouter();
const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor();
useAutoConnect();
const isLargeScreen = width > 768;
const containerStyle = [
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useLMStudio } from './useLMStudio';
/**
* Checks the server once when the app opens, after the saved settings have been
* restored, so the connection badge and model list are not stale until the user
* visits Settings.
*/
export function useAutoConnect() {
const { fetchModels } = useLMStudio();
useEffect(() => {
const { persist } = useAppStore;
if (persist.hasHydrated()) {
void fetchModels();
return;
}
return persist.onFinishHydration(() => void fetchModels());
// Mount only: later checks are triggered by the user (model selector, Settings).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+1
View File
@@ -168,6 +168,7 @@ export const useAppStore = create<AppState>()(
partialize: (state) => ({
theme: state.theme,
serverConfig: state.serverConfig,
activeModelId: state.activeModelId,
chatSessions: state.chatSessions,
messages: state.messages,
currentChatId: state.currentChatId,