Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions components/chat-screen/ChatBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Model } from '../../database/modelRepository';
import { fontFamily, fontSizes, lineHeights } from '../../styles/fontStyles';
import { useTheme } from '../../context/ThemeContext';
import { useLLMStore } from '../../store/llmStore';
import { ScrollView } from 'react-native-gesture-handler';
import { FlatList } from 'react-native';
import RotateLeft from '../../assets/icons/rotate_left.svg';
import { Theme } from '../../styles/colors';
import ChatBarActions from './ChatBarActions';
Expand All @@ -31,7 +31,7 @@ interface Props {
onSelectSource: () => void;
ref: Ref<{ clear: () => void }>;
model: Model | undefined;
scrollRef: RefObject<ScrollView | null>;
scrollRef: RefObject<FlatList | null>;
isAtBottom: boolean;
activeSourcesCount: number;
}
Expand Down Expand Up @@ -129,7 +129,7 @@ const ChatBar = ({
if (!isAtBottom) return;
await loadSelectedModel();
setTimeout(() => {
scrollRef.current?.scrollToEnd({ animated: true });
scrollRef.current?.scrollToOffset({ offset: 0, animated: true });
}, 25);
}}
placeholder="Ask about anything..."
Expand Down
4 changes: 2 additions & 2 deletions components/chat-screen/ChatScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Keyboard, StyleSheet, TextInput, View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { FlatList } from 'react-native';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
import { useLLMStore } from '../../store/llmStore';
import { useChatStore } from '../../store/chatStore';
Expand Down Expand Up @@ -64,7 +64,7 @@ export default function ChatScreen({
selectModel,
}: Props) {
const inputRef = useRef<TextInput>(null);
const scrollRef = useRef<ScrollView>(null);
const scrollRef = useRef<FlatList>(null);
const modelBottomSheetModalRef = useRef<BottomSheetModal>(null);
const sourceBottomSheetModalRef = useRef<BottomSheetModal>(null);
const db = useSQLiteContext();
Expand Down
14 changes: 14 additions & 0 deletions components/chat-screen/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { memo, useMemo, useRef } from 'react';
import { View, StyleSheet, Text, TouchableOpacity } from 'react-native';
import MarkdownComponent from './MarkdownComponent';
import ThinkingBlock from './ThinkingBlock';
import AnimatedChatLoading from './AnimatedChatLoading';
import { fontFamily, fontSizes } from '../../styles/fontStyles';
import { useTheme } from '../../context/ThemeContext';
import { useLLMStore } from '../../store/llmStore';
Expand All @@ -16,6 +17,7 @@ interface MessageItemProps {
tokensPerSecond?: number;
timeToFirstToken?: number;
isLastMessage: boolean;
isLoading?: boolean;
}

const MessageItem = memo(
Expand All @@ -26,6 +28,7 @@ const MessageItem = memo(
tokensPerSecond,
timeToFirstToken,
isLastMessage = false,
isLoading = false,
}: MessageItemProps) => {
const { theme } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
Expand Down Expand Up @@ -73,6 +76,17 @@ const MessageItem = memo(

const contentParts = parseThinkingContent(content);

// Handle loading state
if (isLoading) {
return (
<View style={styles.aiMessage}>
<View style={styles.bubbleContent}>
<AnimatedChatLoading />
</View>
</View>
);
}

return (
<>
{role === 'event' ? (
Expand Down
184 changes: 130 additions & 54 deletions components/chat-screen/Messages.tsx
Original file line number Diff line number Diff line change
@@ -1,100 +1,176 @@
import React, { RefObject, useMemo } from 'react';
import React, {
RefObject,
useMemo,
useCallback,
useEffect,
useRef,
} from 'react';
import {
StyleSheet,
View,
FlatList,
NativeSyntheticEvent,
NativeScrollEvent,
ListRenderItem,
} from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import AnimatedChatLoading from './AnimatedChatLoading';
import MessageItem from './MessageItem';
import { useTheme } from '../../context/ThemeContext';
import { useLLMStore } from '../../store/llmStore';
import { Message } from '../../database/chatRepository';
import { Theme } from '../../styles/colors';

type MessageWithType = Message & { type?: 'message' | 'loading' };

interface Props {
chatHistory: Message[];
ref: RefObject<ScrollView | null>;
ref: RefObject<FlatList | null>;
isAtBottom: boolean;
setIsAtBottom: (value: boolean) => void;
}

const Messages = ({ chatHistory, ref, isAtBottom, setIsAtBottom }: Props) => {
const { theme } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);

const { isProcessingPrompt } = useLLMStore();

const safeHistory = chatHistory || [];
const previousChatLength = useRef(safeHistory.length);
const previousLastMessageContent = useRef(
safeHistory[safeHistory.length - 1]?.content || ''
);
const userScrollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const isUserScrolling = useRef(false);

// Prepare data for FlatList - reverse messages (inverted style)
const flatListData = useMemo(() => {
const messages: MessageWithType[] = [...safeHistory]
.reverse()
.map((msg) => ({
...msg,
type: 'message' as const,
}));

return messages;
}, [safeHistory]);

// Auto-scroll behavior like standard chat apps
useEffect(() => {
const currentLength = safeHistory.length;
const currentLastMessageContent =
safeHistory[safeHistory.length - 1]?.content || '';

// Check if new message was added or existing message content changed
const hasNewMessage = currentLength > previousChatLength.current;
const hasContentUpdate =
currentLastMessageContent !== previousLastMessageContent.current;

if (hasNewMessage && isAtBottom) {
// Only scroll to new message when user is at bottom (inverted FlatList - offset 0 is bottom)
ref.current?.scrollToOffset({ offset: 0, animated: true });
} else if (hasContentUpdate && isAtBottom && !isUserScrolling.current) {
// Only auto-scroll for content updates when at bottom and user not scrolling
ref.current?.scrollToOffset({ offset: 0, animated: true });
}

// Update refs for next comparison
previousChatLength.current = currentLength;
previousLastMessageContent.current = currentLastMessageContent;
}, [safeHistory, ref, isAtBottom, flatListData.length]);

// Clean up timeout on unmount
useEffect(() => {
return () => {
if (userScrollingTimeoutRef.current) {
clearTimeout(userScrollingTimeoutRef.current);
}
};
}, []);

const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const distanceFromBottom =
contentSize.height - (contentOffset.y + layoutMeasurement.height);
setIsAtBottom(distanceFromBottom < 50);
const { contentOffset } = event.nativeEvent;
// For inverted FlatList, we're at "bottom" when offset is near 0
setIsAtBottom(contentOffset.y < 50);

// Mark user as scrolling and reset timeout
isUserScrolling.current = true;
if (userScrollingTimeoutRef.current) {
clearTimeout(userScrollingTimeoutRef.current);
}

// Clear scrolling flag after user stops scrolling
userScrollingTimeoutRef.current = setTimeout(() => {
isUserScrolling.current = false;
}, 150);
};

const renderMessage: ListRenderItem<MessageWithType> = useCallback(
({ item, index }) => {
// For inverted FlatList, the first index (0) is the last message
const isLastMessage = index === 0;

// Check if this is the last message and we're processing (loading state)
const isLoading =
isLastMessage && isProcessingPrompt && item.role === 'assistant';

return (
<MessageItem
content={item.content}
modelName={item.modelName}
role={item.role}
tokensPerSecond={item.tokensPerSecond}
timeToFirstToken={item.timeToFirstToken}
isLastMessage={isLastMessage}
isLoading={isLoading}
/>
);
},
[safeHistory.length, isProcessingPrompt]
);

const keyExtractor = useCallback((item: MessageWithType, index: number) => {
return `${item.role}-${item.id}-${index}`;
}, []);

return (
<View style={styles.container}>
<ScrollView
<FlatList
ref={ref}
data={flatListData}
renderItem={renderMessage}
keyExtractor={keyExtractor}
onScroll={handleScroll}
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={styles.scrollContent}
style={styles.flatList}
keyboardShouldPersistTaps="never"
contentContainerStyle={{ paddingHorizontal: 16 }}
onContentSizeChange={() => {
if (
isAtBottom ||
chatHistory[chatHistory.length - 1]?.content === ''
) {
ref.current?.scrollToEnd({ animated: true });
}
showsVerticalScrollIndicator={true}
inverted
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 10,
}}
>
<View onStartShouldSetResponder={() => true}>
{chatHistory.map((message, index) => {
const isLastMessage = index === chatHistory.length - 1;
return (
<MessageItem
key={`${message.role}-${index}`}
content={message.content}
modelName={message.modelName}
role={message.role}
tokensPerSecond={message.tokensPerSecond}
timeToFirstToken={message.timeToFirstToken}
isLastMessage={isLastMessage}
/>
);
})}
{isProcessingPrompt && (
<View style={styles.aiRow}>
<View style={styles.loadingWrapper}>
<AnimatedChatLoading />
</View>
</View>
)}
</View>
</ScrollView>
// Performance optimizations
removeClippedSubviews={false} // Keep this false for chat to prevent visual issues
/>
</View>
);
};

export default Messages;

const createStyles = (theme: Theme) =>
const createStyles = (_theme: Theme) =>
StyleSheet.create({
container: {
flex: 1,
width: '100%',
justifyContent: 'flex-start', // Start from top
},
aiRow: {
flexDirection: 'row',
maxWidth: '85%',
alignSelf: 'flex-start',
marginVertical: 8,
flatList: {
flexGrow: 0, // Don't grow to fill container
flexShrink: 1, // Allow shrinking if needed
},
loadingWrapper: {
height: 20,
justifyContent: 'center',
paddingTop: 4,
scrollContent: {
paddingHorizontal: 16,
},
});
2 changes: 1 addition & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2743,7 +2743,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: f3e842e6cb5a825b6918a74a38402ba1409411f8
ReactCodegen: 6cb6e0d0b52471abc883541c76589d1c367c64c7
ReactCommon: 1ab5451fc5da87c4cc4c3046e19a8054624ca763
RNAudioAPI: 242a6b4c3c20178239e287abca534fac88748b9c
RNAudioAPI: 99ed120b28fcf8aacb4efc0d7d4f4ad6b895b430
RNCAsyncStorage: 39c42c1e478e1f5166d1db52b5055e090e85ad66
RNCClipboard: 37de6995ef72dc869422879e51a46a520d3f08b3
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
Expand Down