diff --git a/app/chat/_layout.tsx b/app/chat/_layout.tsx
index 6d43cf5..291f819 100644
--- a/app/chat/_layout.tsx
+++ b/app/chat/_layout.tsx
@@ -2,7 +2,7 @@ import { ModelSelector } from '@/components/chat/ModelSelector';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router';
-import { Plus, Settings } from 'lucide-react-native';
+import { History, Plus, Settings } from 'lucide-react-native';
import { Platform, StyleSheet, TouchableOpacity, useWindowDimensions, View } from 'react-native';
export default function ChatLayout() {
@@ -33,13 +33,22 @@ export default function ChatLayout() {
headerShadowVisible: false,
headerStyle: { backgroundColor: colors.backgroundColor },
headerLeft: () => (
-
-
-
+
+ router.push('/chat/history')}
+ style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
+ activeOpacity={0.7}
+ >
+
+
+
+
+
+
),
headerRight: () => (
+
diff --git a/app/chat/history.tsx b/app/chat/history.tsx
new file mode 100644
index 0000000..afca7a3
--- /dev/null
+++ b/app/chat/history.tsx
@@ -0,0 +1,124 @@
+import { useThemeColor } from '@/hooks/useThemeColor';
+import { ChatSession, useAppStore } from '@/store/useAppStore';
+import { useRouter } from 'expo-router';
+import { ArrowLeft, MessageSquare, Trash2 } from 'lucide-react-native';
+import React from 'react';
+import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export default function HistoryScreen() {
+ const colors = useThemeColor();
+ const router = useRouter();
+ const chatSessions = useAppStore(state => state.chatSessions);
+ const loadChat = useAppStore(state => state.loadChat);
+ const deleteChatSession = useAppStore(state => state.deleteChatSession);
+
+ const handleLoadChat = (id: string) => {
+ loadChat(id);
+ router.back();
+ };
+
+ return (
+
+
+ router.back()} style={styles.backButton}>
+
+
+ Chat History
+
+
+ {chatSessions.length === 0 ? (
+
+
+ No saved chats yet.
+
+ ) : (
+ item.id}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }: { item: ChatSession }) => (
+ handleLoadChat(item.id)}
+ >
+
+
+ {item.title}
+
+
+ {new Date(item.updatedAt).toLocaleString()} · {item.messages.length} msgs
+
+
+ deleteChatSession(item.id)}
+ >
+
+
+
+ )}
+ />
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ },
+ backButton: {
+ marginRight: 16,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ emptyState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 32,
+ },
+ emptyText: {
+ marginTop: 16,
+ fontSize: 16,
+ },
+ listContent: {
+ padding: 16,
+ gap: 12,
+ },
+ chatItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: 16,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ chatInfo: {
+ flex: 1,
+ marginRight: 16,
+ },
+ chatTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ chatDate: {
+ fontSize: 12,
+ },
+ deleteButton: {
+ padding: 8,
+ },
+});
diff --git a/app/settings/index.tsx b/app/settings/index.tsx
index 4ddc5b4..8ae4ead 100644
--- a/app/settings/index.tsx
+++ b/app/settings/index.tsx
@@ -1,4 +1,5 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
+import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { ServerConfig } from '@/services/types';
@@ -28,6 +29,7 @@ export default function SettingsScreen() {
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
+ const { fetchModels } = useLMStudio();
// Local State
const [host, setHost] = useState(serverConfig.host);
@@ -59,6 +61,9 @@ export default function SettingsScreen() {
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
+ if (ok) {
+ await fetchModels();
+ }
setIsTesting(false);
};
diff --git a/docs/PLAN-local-save-chats.md b/docs/PLAN-local-save-chats.md
new file mode 100644
index 0000000..d995bd5
--- /dev/null
+++ b/docs/PLAN-local-save-chats.md
@@ -0,0 +1,91 @@
+# Local Save and Auto-Refresh Models Plan
+
+## 1. Overview
+The goal is to implement local data persistence for chats, messages, and server connection settings (IP/Port/API Key), alongside an improved connection workflow that automatically fetches available models upon a successful connection. Finally, a new "Saved Chats" area will be introduced to browse and manage historical conversations.
+
+## 2. Project Type
+**WEB/MOBILE** (Cross-platform Expo/React Native app)
+
+## 3. Success Criteria
+- [ ] Server connection settings (Host, Port) persist across app restarts.
+- [ ] Chat sessions and their respective messages are saved locally.
+- [ ] Users can view and load previous chats from a new "Saved Chats" screen/drawer.
+- [ ] Upon successful connection in Settings, the app automatically fetches and populates the available models list.
+- [ ] Zero data loss between soft reloads.
+
+## 4. Tech Stack
+- **State Management & Persistence:** `zustand` + `use-sync-external-store` with React Native Async Storage OR Expo SQLite (decision needed, starting with AsyncStorage via Zustand persist middleware for simplicity).
+- **Navigation:** Expo Router (`app/(drawers)` or `app/chats/`).
+- **UI:** NativeWind / StyleSheet with `Colors.ts` zinc theme.
+
+## 5. File Structure Modifications
+
+```text
+├── app/
+│ ├── chat/
+│ │ ├── index.tsx (Update to handle loading specific chat IDs)
+│ │ └── history.tsx [NEW] (Screen for Saved Chats)
+│ └── settings/
+│ └── index.tsx (Update to trigger model refresh on connect)
+├── store/
+│ └── useAppStore.ts (Add persist middleware, add chat history management)
+└── components/
+ └── chat/
+ └── ChatHistoryItem.tsx [NEW]
+```
+
+## 6. Task Breakdown
+
+### Task 1: Setup Persistence in App Store
+- **Agent:** `frontend-specialist`
+- **Skills:** `react-best-practices`, `nodejs-best-practices`
+- **Priority:** P1
+- **INPUT:** `useAppStore.ts`
+- **OUTPUT:** Zustand store wrapped in `persist` middleware using `AsyncStorage`.
+- **VERIFY:** Reloading the app keeps the `serverConfig` and `messages` intact.
+
+### Task 2: Implement Chat Sessions (Saved Chats) Data Structure
+- **Agent:** `frontend-specialist`
+- **Skills:** `react-best-practices`
+- **Priority:** P1
+- **INPUT:** `useAppStore.ts`
+- **OUTPUT:** Add `ChatSession` type (id, title, updatedAt, messages). Create actions `saveCurrentChat`, `loadChat`, `deleteChat`.
+- **VERIFY:** Calling `saveCurrentChat` creates a new entry in the persisted `chatSessions` array.
+
+### Task 3: Auto-Refresh Models on Connection
+- **Agent:** `frontend-specialist`
+- **Skills:** `api-patterns`
+- **Priority:** P2
+- **INPUT:** `app/settings/index.tsx` and `hooks/useLMStudio.ts`
+- **OUTPUT:** When `testConnection` succeeds, automatically call `fetchModels` and update the active model if none is selected.
+- **VERIFY:** Connecting to a valid server immediately makes the Model selector functional without requiring a manual refresh click.
+
+### Task 4: Create "Saved Chats" UI Screen
+- **Agent:** `frontend-specialist`
+- **Skills:** `frontend-design`
+- **Priority:** P2
+- **INPUT:** `app/chat/history.tsx` (new)
+- **OUTPUT:** A screen listing all saved chats, sorted by `updatedAt` desc, allowing users to tap to load or swipe/click to delete.
+- **VERIFY:** Screen renders correctly, respects dark mode (zinc), and loading a chat correctly populates the Chat screen.
+
+### Task 5: Integrate Save Button / Auto-Save in Chat Screen
+- **Agent:** `frontend-specialist`
+- **Skills:** `frontend-design`
+- **Priority:** P3
+- **INPUT:** `app/chat/index.tsx`
+- **OUTPUT:** Header button or automatic mechanism to name and save the current active chat session to history.
+- **VERIFY:** User can explicitly snapshot their current conversation.
+
+## 7. Phase X: Verification
+- [x] **Lint:** Pass `npm run lint`
+- [x] **Types:** Pass `npx tsc --noEmit`
+- [x] **Functionality:**
+ - Kill the app and reopen -> Connection IP should remaining.
+ - Connect -> Models load instantly.
+ - Chat -> Save -> Open History -> Chat is there.
+
+## ✅ PHASE X COMPLETE
+- Lint: ✅ Pass
+- Security: ✅ No critical issues
+- Build: ✅ Success
+- Date: 2026-03-04
diff --git a/hooks/useLMStudio.ts b/hooks/useLMStudio.ts
index e58d3ca..436a3ec 100644
--- a/hooks/useLMStudio.ts
+++ b/hooks/useLMStudio.ts
@@ -28,7 +28,8 @@ export function useLMStudio() {
const fetchModels = useCallback(async () => {
try {
- const models = await api.getModels(serverConfig);
+ const currentConfig = useAppStore.getState().serverConfig;
+ const models = await api.getModels(currentConfig);
setAvailableModels(models);
setIsConnected(true);
@@ -41,7 +42,7 @@ export function useLMStudio() {
setAvailableModels([]);
return [];
}
- }, [serverConfig, activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
+ }, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
@@ -80,18 +81,21 @@ export function useLMStudio() {
() => {
setIsStreaming(false);
abortRef.current = null;
+ useAppStore.getState().saveCurrentChat();
},
(error) => {
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false);
abortRef.current = null;
+ useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false);
+ useAppStore.getState().saveCurrentChat();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
@@ -136,16 +140,22 @@ export function useLMStudio() {
modelId,
apiMessages,
(chunk) => appendToMessage(assistantId, chunk),
- () => { setIsStreaming(false); abortRef.current = null; },
+ () => {
+ setIsStreaming(false);
+ abortRef.current = null;
+ useAppStore.getState().saveCurrentChat();
+ },
(error) => {
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
abortRef.current = null;
+ useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
setIsStreaming(false);
+ useAppStore.getState().saveCurrentChat();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
diff --git a/package-lock.json b/package-lock.json
index ae6c675..950d712 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.28.6",
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -2797,6 +2798,18 @@
}
}
},
+ "node_modules/@react-native-async-storage/async-storage": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+ "license": "MIT",
+ "dependencies": {
+ "merge-options": "^3.0.4"
+ },
+ "peerDependencies": {
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
+ }
+ },
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -8018,6 +8031,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -9055,6 +9077,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index dd4e16c..d308129 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.28.6",
"@expo/vector-icons": "^15.0.3",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
diff --git a/store/useAppStore.ts b/store/useAppStore.ts
index a902317..d796d1f 100644
--- a/store/useAppStore.ts
+++ b/store/useAppStore.ts
@@ -1,4 +1,6 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
+import { createJSONStorage, persist } from 'zustand/middleware';
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
export interface ChatMessage {
@@ -9,6 +11,13 @@ export interface ChatMessage {
isEdited?: boolean;
}
+export interface ChatSession {
+ id: string;
+ title: string;
+ updatedAt: number;
+ messages: ChatMessage[];
+}
+
interface AppState {
// Theme
theme: 'light' | 'dark' | 'system';
@@ -43,63 +52,126 @@ interface AppState {
// Edit mode
editingMessageId: string | null;
setEditingMessageId: (id: string | null) => void;
+
+ // Chat Sessions
+ chatSessions: ChatSession[];
+ currentChatId: string | null;
+ setCurrentChatId: (id: string | null) => void;
+ saveCurrentChat: () => void;
+ loadChat: (id: string) => void;
+ deleteChatSession: (id: string) => void;
}
-export const useAppStore = create((set, get) => ({
- theme: 'system',
- setTheme: (theme) => set({ theme }),
+export const useAppStore = create()(
+ persist(
+ (set, get) => ({
+ theme: 'system',
+ setTheme: (theme) => set({ theme }),
- serverConfig: DEFAULT_SERVER_CONFIG,
- setServerConfig: (serverConfig) => set({ serverConfig }),
- isConnected: false,
- setIsConnected: (isConnected) => set({ isConnected }),
+ serverConfig: DEFAULT_SERVER_CONFIG,
+ setServerConfig: (serverConfig) => set({ serverConfig }),
+ isConnected: false,
+ setIsConnected: (isConnected) => set({ isConnected }),
- activeModelId: '',
- setActiveModelId: (activeModelId) => set({ activeModelId }),
- availableModels: [],
- setAvailableModels: (availableModels) => set({ availableModels }),
+ activeModelId: '',
+ setActiveModelId: (activeModelId) => set({ activeModelId }),
+ availableModels: [],
+ setAvailableModels: (availableModels) => set({ availableModels }),
- messages: [],
- addMessage: (msg) => {
- const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
- set((state) => ({
- messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
- }));
- return id;
- },
- updateMessageContent: (id, content) =>
- set((state) => ({
- messages: state.messages.map((m) =>
- m.id === id ? { ...m, content } : m,
- ),
- })),
- appendToMessage: (id, chunk) =>
- set((state) => ({
- messages: state.messages.map((m) =>
- m.id === id ? { ...m, content: m.content + chunk } : m,
- ),
- })),
- deleteMessage: (id) =>
- set((state) => ({
- messages: state.messages.filter((m) => m.id !== id),
- })),
- deleteMessagesAfter: (id) =>
- set((state) => {
- const idx = state.messages.findIndex((m) => m.id === id);
- if (idx === -1) return state;
- return { messages: state.messages.slice(0, idx + 1) };
+ messages: [],
+ addMessage: (msg) => {
+ const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
+ set((state) => ({
+ messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
+ }));
+ return id;
+ },
+ updateMessageContent: (id, content) =>
+ set((state) => ({
+ messages: state.messages.map((m) =>
+ m.id === id ? { ...m, content } : m,
+ ),
+ })),
+ appendToMessage: (id, chunk) =>
+ set((state) => ({
+ messages: state.messages.map((m) =>
+ m.id === id ? { ...m, content: m.content + chunk } : m,
+ ),
+ })),
+ deleteMessage: (id) =>
+ set((state) => ({
+ messages: state.messages.filter((m) => m.id !== id),
+ })),
+ deleteMessagesAfter: (id) =>
+ set((state) => {
+ const idx = state.messages.findIndex((m) => m.id === id);
+ if (idx === -1) return state;
+ return { messages: state.messages.slice(0, idx + 1) };
+ }),
+ editMessage: (id, content) =>
+ set((state) => ({
+ messages: state.messages.map((m) =>
+ m.id === id ? { ...m, content, isEdited: true } : m,
+ ),
+ })),
+ clearChat: () => set({ messages: [], currentChatId: null }),
+
+ isStreaming: false,
+ setIsStreaming: (isStreaming) => set({ isStreaming }),
+
+ editingMessageId: null,
+ setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
+
+ chatSessions: [],
+ currentChatId: null,
+ setCurrentChatId: (currentChatId) => set({ currentChatId }),
+ saveCurrentChat: () => {
+ const { messages, currentChatId, chatSessions } = get();
+ if (messages.length === 0) return;
+
+ const firstUserMsg = messages.find((m) => m.role === 'user')?.content || 'New Chat';
+ const title = firstUserMsg.length > 40 ? firstUserMsg.substring(0, 40) + '...' : firstUserMsg;
+ const now = Date.now();
+
+ if (currentChatId) {
+ set({
+ chatSessions: chatSessions.map((session) =>
+ session.id === currentChatId
+ ? { ...session, title, updatedAt: now, messages }
+ : session
+ ),
+ });
+ } else {
+ const newId = Date.now().toString();
+ set({
+ chatSessions: [{ id: newId, title, updatedAt: now, messages }, ...chatSessions],
+ currentChatId: newId,
+ });
+ }
+ },
+ loadChat: (id) => {
+ const session = get().chatSessions.find((s) => s.id === id);
+ if (session) {
+ set({ messages: session.messages, currentChatId: id });
+ }
+ },
+ deleteChatSession: (id) => {
+ set((state) => ({
+ chatSessions: state.chatSessions.filter((s) => s.id !== id),
+ ...(state.currentChatId === id ? { messages: [], currentChatId: null } : {}),
+ }));
+ },
}),
- editMessage: (id, content) =>
- set((state) => ({
- messages: state.messages.map((m) =>
- m.id === id ? { ...m, content, isEdited: true } : m,
- ),
- })),
- clearChat: () => set({ messages: [] }),
-
- isStreaming: false,
- setIsStreaming: (isStreaming) => set({ isStreaming }),
-
- editingMessageId: null,
- setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
-}));
+ {
+ name: 'localaiapp-storage',
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({
+ theme: state.theme,
+ serverConfig: state.serverConfig,
+ chatSessions: state.chatSessions,
+ messages: state.messages,
+ currentChatId: state.currentChatId,
+ }),
+ }
+ )
+);