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,