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 === ''; +}