From 541aefd7e4cab89c6e0d146a290f2d44c4220f3a Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 10 Oct 2026 04:31:21 -0300 Subject: [PATCH] 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. --- __tests__/useAppStore.test.ts | 6 +++-- __tests__/useAutoConnect.test.ts | 42 ++++++++++++++++++++++++++++++++ app/chat/_layout.tsx | 2 ++ hooks/useAutoConnect.ts | 23 +++++++++++++++++ store/useAppStore.ts | 1 + 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 __tests__/useAutoConnect.test.ts create mode 100644 hooks/useAutoConnect.ts diff --git a/__tests__/useAppStore.test.ts b/__tests__/useAppStore.test.ts index 07619f7..0293548 100644 --- a/__tests__/useAppStore.test.ts +++ b/__tests__/useAppStore.test.ts @@ -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'); }); }); diff --git a/__tests__/useAutoConnect.test.ts b/__tests__/useAutoConnect.test.ts new file mode 100644 index 0000000..67a6884 --- /dev/null +++ b/__tests__/useAutoConnect.test.ts @@ -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; + +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); + }); +}); diff --git a/app/chat/_layout.tsx b/app/chat/_layout.tsx index 288c55f..ddc1c83 100644 --- a/app/chat/_layout.tsx +++ b/app/chat/_layout.tsx @@ -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 = [ diff --git a/hooks/useAutoConnect.ts b/hooks/useAutoConnect.ts new file mode 100644 index 0000000..73de512 --- /dev/null +++ b/hooks/useAutoConnect.ts @@ -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 + }, []); +} diff --git a/store/useAppStore.ts b/store/useAppStore.ts index 22856fe..3774e39 100644 --- a/store/useAppStore.ts +++ b/store/useAppStore.ts @@ -168,6 +168,7 @@ export const useAppStore = create()( partialize: (state) => ({ theme: state.theme, serverConfig: state.serverConfig, + activeModelId: state.activeModelId, chatSessions: state.chatSessions, messages: state.messages, currentChatId: state.currentChatId,