diff --git a/__tests__/ChatScreen.test.tsx b/__tests__/ChatScreen.test.tsx
new file mode 100644
index 0000000..ebb943d
--- /dev/null
+++ b/__tests__/ChatScreen.test.tsx
@@ -0,0 +1,220 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+import ChatScreen from '../app/chat/index';
+import * as api from '../services/lmStudioApi';
+import { useAppStore } from '../store/useAppStore';
+
+jest.mock('../services/lmStudioApi');
+jest.mock('react-native-reanimated', () => {
+ const { View } = require('react-native');
+ const chain: any = new Proxy(() => chain, { get: () => chain });
+ return { __esModule: true, default: { View }, FadeInDown: chain };
+});
+jest.mock('../components/chat/MessageBubble', () => {
+ const { Text } = require('react-native');
+ return {
+ MessageBubble: ({ message, isLastAssistant }: any) => (
+ {`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}
+ ),
+ };
+});
+jest.mock('../components/chat/TypingIndicator', () => {
+ const { Text } = require('react-native');
+ return { TypingIndicator: () => typing-indicator };
+});
+
+let inputProps: any;
+jest.mock('../components/chat/ChatInput', () => ({
+ ChatInput: (props: any) => {
+ inputProps = props;
+ return null;
+ },
+}));
+
+const streamMock = api.chatCompletionStream as jest.Mock;
+const state = () => useAppStore.getState();
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ streamMock.mockReset();
+ useAppStore.setState(useAppStore.getInitialState(), true);
+ useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe('ChatScreen', () => {
+ it('shows the empty state with prompt suggestions when there are no messages', () => {
+ render();
+ expect(screen.getByText('Start a conversation')).toBeTruthy();
+ expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
+ });
+
+ it('sends a suggestion as a user message and starts streaming', () => {
+ render();
+ fireEvent.press(screen.getByText('Explain recursion in simple terms'));
+
+ expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
+ 'user:Explain recursion in simple terms',
+ 'assistant:',
+ ]);
+ expect(streamMock).toHaveBeenCalledTimes(1);
+ expect(streamMock.mock.calls[0][1]).toBe('model-a');
+ });
+
+ it('hides the empty state and lists the messages once there are some', () => {
+ state().addMessage({ role: 'user', content: 'hello' });
+ render();
+ expect(screen.queryByText('Start a conversation')).toBeNull();
+ expect(screen.getByText('user:hello')).toBeTruthy();
+ });
+
+ it('shows the offline banner only when the server is not connected', () => {
+ const { rerender } = render();
+ expect(screen.queryByText(/Server offline/)).toBeNull();
+
+ act(() => {
+ state().setIsConnected(false);
+ });
+ rerender();
+ expect(screen.getByText(/Server offline/)).toBeTruthy();
+ });
+
+ it('shows the typing indicator only while an empty assistant message is streaming', () => {
+ state().addMessage({ role: 'user', content: 'q' });
+ const assistantId = state().addMessage({ role: 'assistant', content: '' });
+ act(() => {
+ state().setIsStreaming(true);
+ });
+ render();
+ expect(screen.getByText('typing-indicator')).toBeTruthy();
+
+ act(() => {
+ state().appendToMessage(assistantId, 'partial');
+ });
+ expect(screen.queryByText('typing-indicator')).toBeNull();
+ });
+
+ it('does not show the typing indicator when not streaming', () => {
+ state().addMessage({ role: 'assistant', content: '' });
+ render();
+ expect(screen.queryByText('typing-indicator')).toBeNull();
+ });
+
+ it('marks only the last assistant message as regenerable', () => {
+ state().addMessage({ role: 'user', content: 'q1' });
+ state().addMessage({ role: 'assistant', content: 'a1' });
+ state().addMessage({ role: 'user', content: 'q2' });
+ state().addMessage({ role: 'assistant', content: 'a2' });
+ render();
+ expect(screen.getByText('assistant:a1')).toBeTruthy();
+ expect(screen.getByText('assistant:a2:last')).toBeTruthy();
+ });
+
+ it('sends typed text through the normal path', () => {
+ render();
+ act(() => {
+ inputProps.onSend('typed');
+ });
+ expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
+ expect(streamMock).toHaveBeenCalledTimes(1);
+ });
+
+ describe('editing a message', () => {
+ const setup = () => {
+ state().addMessage({ role: 'user', content: 'old question' });
+ state().addMessage({ role: 'assistant', content: 'old answer' });
+ const id = state().messages[0].id;
+ render();
+ act(() => {
+ state().setEditingMessageId(id);
+ });
+ return id;
+ };
+
+ it('passes the edited message text to the input and clears edit mode on cancel', () => {
+ setup();
+ expect(inputProps.editingText).toBe('old question');
+ act(() => {
+ inputProps.onCancelEdit();
+ });
+ expect(state().editingMessageId).toBeNull();
+ });
+
+ it('rewrites the message, drops the answers after it and regenerates', () => {
+ const id = setup();
+ act(() => {
+ inputProps.onSend('new question');
+ });
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(state().editingMessageId).toBeNull();
+ expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
+ expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
+ 'user:new question',
+ 'assistant:',
+ ]);
+ expect(streamMock).toHaveBeenCalledTimes(1);
+ const [, modelId, apiMessages] = streamMock.mock.calls[0];
+ expect(modelId).toBe('model-a');
+ expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
+ expect(apiMessages[0].role).toBe('system');
+ });
+
+ it('edits without regenerating when no model is selected', () => {
+ const id = setup();
+ act(() => {
+ useAppStore.setState({ activeModelId: '' });
+ });
+ act(() => {
+ inputProps.onSend('new question');
+ });
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(state().messages).toHaveLength(1);
+ expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
+ expect(streamMock).not.toHaveBeenCalled();
+ });
+
+ it('lets the user stop an answer generated after an edit', () => {
+ const abort = jest.fn();
+ streamMock.mockReturnValue({ abort });
+ setup();
+ act(() => {
+ inputProps.onSend('new question');
+ });
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+ act(() => {
+ inputProps.onStop();
+ });
+ expect(abort).toHaveBeenCalledTimes(1);
+ expect(state().isStreaming).toBe(false);
+ });
+
+ it('saves the edited conversation when the regenerated answer finishes', () => {
+ streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
+ onChunk('fresh answer');
+ onDone();
+ return { abort: jest.fn() };
+ });
+ setup();
+ act(() => {
+ inputProps.onSend('new question');
+ });
+ act(() => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(state().chatSessions).toHaveLength(1);
+ expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
+ });
+ });
+});
diff --git a/__tests__/useLMStudio.test.ts b/__tests__/useLMStudio.test.ts
new file mode 100644
index 0000000..555b9ff
--- /dev/null
+++ b/__tests__/useLMStudio.test.ts
@@ -0,0 +1,234 @@
+import { act, renderHook } from '@testing-library/react-native';
+import { useLMStudio } from '../hooks/useLMStudio';
+import * as api from '../services/lmStudioApi';
+import { useAppStore } from '../store/useAppStore';
+
+jest.mock('../services/lmStudioApi');
+
+const mockApi = api as jest.Mocked;
+const state = () => useAppStore.getState();
+
+type StreamArgs = Parameters;
+const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
+
+beforeEach(() => {
+ jest.resetAllMocks();
+ useAppStore.setState(useAppStore.getInitialState(), true);
+ useAppStore.setState({ activeModelId: 'model-a' });
+ mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
+});
+
+describe('checkConnection', () => {
+ it('stores the result of the connection test', async () => {
+ mockApi.testConnection.mockResolvedValue(true);
+ const { result } = renderHook(() => useLMStudio());
+
+ await act(async () => {
+ await expect(result.current.checkConnection()).resolves.toBe(true);
+ });
+ expect(state().isConnected).toBe(true);
+ });
+});
+
+describe('fetchModels', () => {
+ const models = [
+ { id: 'first', object: 'model', owned_by: 'x' },
+ { id: 'second', object: 'model', owned_by: 'x' },
+ ];
+
+ it('stores the models, marks the server connected and selects the first model if none is active', async () => {
+ useAppStore.setState({ activeModelId: '' });
+ mockApi.getModels.mockResolvedValue(models);
+ const { result } = renderHook(() => useLMStudio());
+
+ await act(async () => {
+ await result.current.fetchModels();
+ });
+ expect(state().availableModels).toEqual(models);
+ expect(state().isConnected).toBe(true);
+ expect(state().activeModelId).toBe('first');
+ });
+
+ it('keeps the model that was already selected', async () => {
+ mockApi.getModels.mockResolvedValue(models);
+ const { result } = renderHook(() => useLMStudio());
+
+ await act(async () => {
+ await result.current.fetchModels();
+ });
+ expect(state().activeModelId).toBe('model-a');
+ });
+
+ it('does not select anything when the server has no models', async () => {
+ useAppStore.setState({ activeModelId: '' });
+ mockApi.getModels.mockResolvedValue([]);
+ const { result } = renderHook(() => useLMStudio());
+
+ await act(async () => {
+ await result.current.fetchModels();
+ });
+ expect(state().activeModelId).toBe('');
+ });
+
+ it('marks the server offline and clears the list when the request fails', async () => {
+ useAppStore.setState({ isConnected: true, availableModels: models });
+ mockApi.getModels.mockRejectedValue(new Error('down'));
+ const { result } = renderHook(() => useLMStudio());
+
+ let returned: unknown;
+ await act(async () => {
+ returned = await result.current.fetchModels();
+ });
+ expect(returned).toEqual([]);
+ expect(state().isConnected).toBe(false);
+ expect(state().availableModels).toEqual([]);
+ });
+});
+
+describe('sendMessage', () => {
+ it('streams an answer for the conversation, prefixed by the system prompt', async () => {
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('hello');
+ });
+
+ const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
+ expect(modelId).toBe('model-a');
+ expect(apiMessages[0].role).toBe('system');
+ expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
+ expect(state().isStreaming).toBe(true);
+
+ act(() => {
+ onChunk('Hi');
+ onChunk(' there');
+ onDone();
+ });
+ expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
+ expect(state().isStreaming).toBe(false);
+ expect(state().chatSessions).toHaveLength(1);
+ });
+
+ it('asks for a model instead of calling the server when none is selected', async () => {
+ useAppStore.setState({ activeModelId: '' });
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('hello');
+ });
+
+ expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
+ expect(state().messages[1].content).toContain('No model selected');
+ expect(state().isStreaming).toBe(false);
+ });
+
+ it('ignores new messages while an answer is streaming', async () => {
+ useAppStore.setState({ isStreaming: true });
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('again');
+ });
+
+ expect(state().messages).toEqual([]);
+ expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
+ });
+
+ it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('hello');
+ });
+
+ const onError = lastStreamCall()[5];
+ act(() => {
+ onError(new Error('Could not reach the server'));
+ });
+ expect(state().messages[1].content).toContain('Error: Could not reach the server');
+ expect(state().isStreaming).toBe(false);
+ expect(state().chatSessions).toHaveLength(1);
+ });
+
+ it('reports a synchronous failure to start the stream', async () => {
+ mockApi.chatCompletionStream.mockImplementation(() => {
+ throw new Error('boom');
+ });
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('hello');
+ });
+
+ expect(state().messages[1].content).toContain('Connection failed: boom');
+ expect(state().isStreaming).toBe(false);
+ });
+});
+
+describe('regenerateResponse', () => {
+ it('drops everything after the last user message and asks again', async () => {
+ state().addMessage({ role: 'user', content: 'q1' });
+ state().addMessage({ role: 'assistant', content: 'a1' });
+ state().addMessage({ role: 'user', content: 'q2' });
+ state().addMessage({ role: 'assistant', content: 'a2' });
+ const { result } = renderHook(() => useLMStudio());
+
+ await act(async () => {
+ await result.current.regenerateResponse();
+ });
+ const apiMessages = lastStreamCall()[2];
+ expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
+ expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
+ expect(state().isStreaming).toBe(true);
+ });
+
+ it('does nothing without a user message, while streaming, or without a model', async () => {
+ const { result, rerender } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.regenerateResponse();
+ });
+ expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
+
+ act(() => {
+ state().addMessage({ role: 'user', content: 'q' });
+ useAppStore.setState({ activeModelId: '' });
+ });
+ await act(async () => {
+ await result.current.regenerateResponse();
+ });
+ expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
+
+ act(() => {
+ useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
+ });
+ rerender({});
+ await act(async () => {
+ await result.current.regenerateResponse();
+ });
+ expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
+ });
+});
+
+describe('stopStreaming', () => {
+ it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
+ const abort = jest.fn();
+ mockApi.chatCompletionStream.mockReturnValue({ abort });
+ const { result } = renderHook(() => useLMStudio());
+ await act(async () => {
+ await result.current.sendMessage('hello');
+ });
+ act(() => {
+ lastStreamCall()[3]('partial');
+ });
+
+ act(() => {
+ result.current.stopStreaming();
+ });
+ expect(abort).toHaveBeenCalledTimes(1);
+ expect(state().isStreaming).toBe(false);
+ expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
+ });
+
+ it('is a no-op when nothing is streaming', () => {
+ const { result } = renderHook(() => useLMStudio());
+ act(() => {
+ result.current.stopStreaming();
+ });
+ expect(state().chatSessions).toEqual([]);
+ });
+});
diff --git a/app/chat/index.tsx b/app/chat/index.tsx
index 471275f..e9ce009 100644
--- a/app/chat/index.tsx
+++ b/app/chat/index.tsx
@@ -1,20 +1,12 @@
import { ChatInput } from '@/components/chat/ChatInput';
-import { MessageBubble } from '@/components/chat/MessageBubble';
-import { TypingIndicator } from '@/components/chat/TypingIndicator';
+import { EmptyState } from '@/components/chat/EmptyState';
+import { MessageList } from '@/components/chat/MessageList';
+import { OfflineBanner } from '@/components/chat/OfflineBanner';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
-import { MessageSquare, Sparkles } from 'lucide-react-native';
-import React, { useCallback, useEffect, useRef } from 'react';
-import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
-import Animated, { FadeInDown } from 'react-native-reanimated';
-
-const PROMPT_SUGGESTIONS = [
- 'Explain recursion in simple terms',
- 'Write a short poem about coding',
- 'What are the best practices for React Native?',
- 'Help me debug a JavaScript error',
-];
+import React, { useCallback } from 'react';
+import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme
@@ -25,7 +17,6 @@ export default function ChatScreen() {
const deleteMessage = useAppStore(state => state.deleteMessage);
const editMessage = useAppStore(state => state.editMessage);
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
- const flatListRef = useRef(null);
// Find the editing message content
const editingMessage = editingMessageId
@@ -37,122 +28,35 @@ export default function ChatScreen() {
editMessage(editingMessageId, text);
deleteMessagesAfter(editingMessageId);
setEditingMessageId(null);
-
- setTimeout(() => {
- const store = useAppStore.getState();
- const msgs = store.messages;
- // Add system prompt before mapped messages
- const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
- const apiMsgs = [
- { role: 'system', content: SYSTEM_PROMPT },
- ...msgs.map(m => ({ role: m.role, content: m.content }))
- ];
-
- const modelId = store.activeModelId;
- if (!modelId) return;
-
- const assistantId = store.addMessage({ role: 'assistant', content: '' });
- store.setIsStreaming(true);
-
- const { chatCompletionStream } = require('@/services/lmStudioApi');
- chatCompletionStream(
- store.serverConfig,
- modelId,
- apiMsgs,
- (chunk: string) => store.appendToMessage(assistantId, chunk),
- () => { store.setIsStreaming(false); },
- (error: Error) => {
- store.appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
- store.setIsStreaming(false);
- },
- );
- }, 100);
+ // The edited message is now the last one, so regenerating answers it.
+ regenerateResponse();
} else {
sendMessage(text);
}
- }, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
+ }, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, sendMessage]);
const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
- const lastAssistantId = messages.length > 0
- ? [...messages].reverse().find(m => m.role === 'assistant')?.id
- : null;
-
- useEffect(() => {
- if (messages.length > 0 && flatListRef.current) {
- setTimeout(() => {
- flatListRef.current?.scrollToEnd({ animated: !isStreaming });
- }, 100);
- }
- }, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
-
- const showTypingIndicator = isStreaming &&
- messages.length > 0 &&
- messages[messages.length - 1]?.role === 'assistant' &&
- messages[messages.length - 1]?.content === '';
-
return (
- {!isConnected && (
-
-
- ⚠️ Server offline. Check Settings → Server Connection.
-
-
- )}
+ {!isConnected && }
{messages.length === 0 ? (
-
-
-
-
- Start a conversation
-
- Send a message to begin chatting with your local AI model.
-
-
-
- {PROMPT_SUGGESTIONS.map((prompt, i) => (
- sendMessage(prompt)}
- activeOpacity={0.7}
- >
-
- {prompt}
-
- ))}
-
-
+
) : (
- <>
- item.id}
- renderItem={({ item, index }) => (
- messages.length - 4 ? 100 : 0)}>
- handleEdit(item.id)}
- onRegenerate={regenerateResponse}
- onDelete={() => handleDelete(item.id)}
- isStreaming={isStreaming}
- isLastAssistant={item.id === lastAssistantId}
- />
-
- )}
- contentContainerStyle={styles.listContent}
- />
- {showTypingIndicator && }
- >
+
)}
void;
+}
+
+export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
+ const colors = useThemeColor();
+
+ return (
+
+
+
+
+ Start a conversation
+
+ Send a message to begin chatting with your local AI model.
+
+
+
+ {PROMPT_SUGGESTIONS.map((prompt) => (
+ onSelectPrompt(prompt)}
+ activeOpacity={0.7}
+ accessibilityRole="button"
+ accessibilityLabel={prompt}
+ >
+
+ {prompt}
+
+ ))}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ emptyState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 32,
+ paddingBottom: 80,
+ },
+ emptyIconWrap: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 20,
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.05,
+ shadowRadius: 10,
+ elevation: 2,
+ },
+ emptyTitle: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginBottom: 8,
+ },
+ emptySubtitle: {
+ fontSize: 15,
+ textAlign: 'center',
+ lineHeight: 22,
+ marginBottom: 32,
+ },
+ suggestionsWrap: {
+ width: '100%',
+ maxWidth: 400,
+ gap: 12,
+ },
+ suggestionChip: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ borderWidth: 1,
+ borderRadius: 16,
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ },
+ suggestionText: {
+ fontSize: 15,
+ flex: 1,
+ fontWeight: '500',
+ },
+});
diff --git a/components/chat/MessageList.tsx b/components/chat/MessageList.tsx
new file mode 100644
index 0000000..bf808b6
--- /dev/null
+++ b/components/chat/MessageList.tsx
@@ -0,0 +1,60 @@
+import { MessageBubble } from '@/components/chat/MessageBubble';
+import { TypingIndicator } from '@/components/chat/TypingIndicator';
+import { ChatMessage } from '@/store/useAppStore';
+import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
+import React, { useEffect, useRef } from 'react';
+import { FlatList, StyleSheet } from 'react-native';
+import Animated, { FadeInDown } from 'react-native-reanimated';
+
+interface MessageListProps {
+ messages: ChatMessage[];
+ isStreaming: boolean;
+ onEdit: (id: string) => void;
+ onDelete: (id: string) => void;
+ onRegenerate: () => void;
+}
+
+export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
+ const flatListRef = useRef(null);
+ const lastAssistantId = findLastAssistantId(messages);
+ const lastContentLength = messages[messages.length - 1]?.content?.length;
+
+ useEffect(() => {
+ if (messages.length > 0 && flatListRef.current) {
+ setTimeout(() => {
+ flatListRef.current?.scrollToEnd({ animated: !isStreaming });
+ }, 100);
+ }
+ }, [messages.length, lastContentLength, isStreaming]);
+
+ return (
+ <>
+ item.id}
+ renderItem={({ item, index }) => (
+ messages.length - 4 ? 100 : 0)}>
+ onEdit(item.id)}
+ onRegenerate={onRegenerate}
+ onDelete={() => onDelete(item.id)}
+ isStreaming={isStreaming}
+ isLastAssistant={item.id === lastAssistantId}
+ />
+
+ )}
+ contentContainerStyle={styles.listContent}
+ />
+ {shouldShowTypingIndicator(messages, isStreaming) && }
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ listContent: {
+ paddingTop: 16,
+ paddingBottom: 24,
+ },
+});
diff --git a/components/chat/OfflineBanner.tsx b/components/chat/OfflineBanner.tsx
new file mode 100644
index 0000000..01a3078
--- /dev/null
+++ b/components/chat/OfflineBanner.tsx
@@ -0,0 +1,31 @@
+import { useThemeColor } from '@/hooks/useThemeColor';
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+export function OfflineBanner() {
+ const colors = useThemeColor();
+
+ return (
+
+
+ ⚠️ Server offline. Check Settings → Server Connection.
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ banner: {
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ borderBottomWidth: 1,
+ },
+ bannerText: {
+ fontSize: 13,
+ textAlign: 'center',
+ fontWeight: '500',
+ },
+});
diff --git a/hooks/useLMStudio.ts b/hooks/useLMStudio.ts
index 436a3ec..13e0af5 100644
--- a/hooks/useLMStudio.ts
+++ b/hooks/useLMStudio.ts
@@ -164,6 +164,7 @@ export function useLMStudio() {
abortRef.current.abort();
abortRef.current = null;
setIsStreaming(false);
+ useAppStore.getState().saveCurrentChat();
}
}, [setIsStreaming]);
diff --git a/utils/chatSelectors.ts b/utils/chatSelectors.ts
new file mode 100644
index 0000000..f29f2ad
--- /dev/null
+++ b/utils/chatSelectors.ts
@@ -0,0 +1,16 @@
+import type { ChatMessage } from '../store/useAppStore';
+
+/** Id of the most recent assistant message, or null when there is none. */
+export function findLastAssistantId(messages: ChatMessage[]): string | null {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === 'assistant') return messages[i].id;
+ }
+ return null;
+}
+
+/** True while the model has been asked to answer but has not produced any text yet. */
+export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
+ if (!isStreaming) return false;
+ const last = messages[messages.length - 1];
+ return last?.role === 'assistant' && last.content === '';
+}