Skip to content

Commit ae56b26

Browse files
grichaclaude
andcommitted
Improve mobile chat scroll and add infinite scroll for history
- Fetch only last 100 messages on initial load using API pagination - Scroll to bottom reliably after messages load - Auto-load older messages when scrolling near top (infinite scroll) - Show loading spinner while fetching older messages - Use maintainVisibleContentPosition to keep scroll stable when prepending 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 577f962 commit ae56b26

1 file changed

Lines changed: 102 additions & 90 deletions

File tree

mobile/src/screens/SessionChatScreen.tsx

Lines changed: 102 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -211,19 +211,22 @@ export function SessionChatScreen({ route, navigation }: any) {
211211
const insets = useSafeAreaInsets()
212212
const { workspaceName, sessionId: initialSessionId, agentType = 'claude-code', isNew } = route.params
213213

214-
const [allMessages, setAllMessages] = useState<ChatMessage[]>([])
215-
const [displayCount, setDisplayCount] = useState(MESSAGES_PER_PAGE)
214+
const [messages, setMessages] = useState<ChatMessage[]>([])
216215
const [input, setInput] = useState('')
217216
const [isStreaming, setIsStreaming] = useState(false)
218217
const [connected, setConnected] = useState(false)
219218
const [currentSessionId, setCurrentSessionId] = useState<string | null>(initialSessionId || null)
220219
const [initialScrollDone, setInitialScrollDone] = useState(false)
221220
const [streamingParts, setStreamingParts] = useState<MessagePart[]>([])
222221
const [keyboardVisible, setKeyboardVisible] = useState(false)
222+
const [hasMoreMessages, setHasMoreMessages] = useState(false)
223+
const [messageOffset, setMessageOffset] = useState(0)
224+
const [isLoadingMore, setIsLoadingMore] = useState(false)
223225
const wsRef = useRef<WebSocket | null>(null)
224226
const flatListRef = useRef<FlatList>(null)
225227
const streamingPartsRef = useRef<MessagePart[]>([])
226228
const messageIdCounter = useRef(0)
229+
const hasLoadedInitial = useRef(false)
227230

228231
useEffect(() => {
229232
const showSub = Keyboard.addListener('keyboardWillShow', () => setKeyboardVisible(true))
@@ -234,65 +237,94 @@ export function SessionChatScreen({ route, navigation }: any) {
234237
}
235238
}, [])
236239

237-
const { data: sessionData, isLoading: sessionLoading } = useQuery({
238-
queryKey: ['session', workspaceName, initialSessionId],
239-
queryFn: () => api.getSession(workspaceName, initialSessionId, agentType),
240-
enabled: !!initialSessionId && !isNew,
241-
})
242-
243240
const generateId = useCallback(() => {
244241
messageIdCounter.current += 1
245242
return `msg-${messageIdCounter.current}`
246243
}, [])
247244

248-
useEffect(() => {
249-
if (sessionData?.messages) {
250-
const converted: ChatMessage[] = []
251-
let currentParts: MessagePart[] = []
252-
253-
const flushParts = () => {
254-
if (currentParts.length > 0) {
255-
const textContent = currentParts
256-
.filter(p => p.type === 'text')
257-
.map(p => p.content)
258-
.join('')
259-
converted.push({
260-
role: 'assistant',
261-
content: textContent || '',
262-
id: generateId(),
263-
parts: [...currentParts],
264-
})
265-
currentParts = []
266-
}
245+
const parseMessages = useCallback((rawMessages: any[]): ChatMessage[] => {
246+
const converted: ChatMessage[] = []
247+
let currentParts: MessagePart[] = []
248+
249+
const flushParts = () => {
250+
if (currentParts.length > 0) {
251+
const textContent = currentParts
252+
.filter(p => p.type === 'text')
253+
.map(p => p.content)
254+
.join('')
255+
converted.push({
256+
role: 'assistant',
257+
content: textContent || '',
258+
id: generateId(),
259+
parts: [...currentParts],
260+
})
261+
currentParts = []
267262
}
263+
}
268264

269-
for (const m of sessionData.messages) {
270-
if (m.type === 'user' && m.content) {
271-
flushParts()
272-
converted.push({ role: 'user', content: m.content, id: generateId() })
273-
} else if (m.type === 'assistant' && m.content) {
274-
currentParts.push({ type: 'text', content: m.content })
275-
} else if (m.type === 'tool_use') {
276-
currentParts.push({
277-
type: 'tool_use',
278-
content: m.toolInput || '',
279-
toolName: m.toolName,
280-
toolId: m.toolId,
281-
})
282-
} else if (m.type === 'tool_result') {
283-
currentParts.push({
284-
type: 'tool_result',
285-
content: m.content || '',
286-
toolId: m.toolId,
287-
})
288-
}
265+
for (const m of rawMessages) {
266+
if (m.type === 'user' && m.content) {
267+
flushParts()
268+
converted.push({ role: 'user', content: m.content, id: generateId() })
269+
} else if (m.type === 'assistant' && m.content) {
270+
currentParts.push({ type: 'text', content: m.content })
271+
} else if (m.type === 'tool_use') {
272+
currentParts.push({
273+
type: 'tool_use',
274+
content: m.toolInput || '',
275+
toolName: m.toolName,
276+
toolId: m.toolId,
277+
})
278+
} else if (m.type === 'tool_result') {
279+
currentParts.push({
280+
type: 'tool_result',
281+
content: m.content || '',
282+
toolId: m.toolId,
283+
})
289284
}
290-
flushParts()
285+
}
286+
flushParts()
287+
288+
return converted
289+
}, [generateId])
290+
291+
const { data: sessionData, isLoading: sessionLoading } = useQuery({
292+
queryKey: ['session', workspaceName, initialSessionId, 'initial'],
293+
queryFn: () => api.getSession(workspaceName, initialSessionId, agentType, MESSAGES_PER_PAGE, 0),
294+
enabled: !!initialSessionId && !isNew,
295+
})
291296

292-
setAllMessages(converted)
293-
setInitialScrollDone(false)
297+
useEffect(() => {
298+
if (sessionData?.messages && !hasLoadedInitial.current) {
299+
hasLoadedInitial.current = true
300+
const converted = parseMessages(sessionData.messages)
301+
setMessages(converted)
302+
setHasMoreMessages(sessionData.hasMore || false)
303+
setMessageOffset(sessionData.messages.length)
304+
setTimeout(() => {
305+
flatListRef.current?.scrollToEnd({ animated: false })
306+
}, 150)
294307
}
295-
}, [sessionData, generateId])
308+
}, [sessionData, parseMessages])
309+
310+
const loadMoreMessages = useCallback(async () => {
311+
if (!hasMoreMessages || isLoadingMore || !initialSessionId) return
312+
313+
setIsLoadingMore(true)
314+
try {
315+
const moreData = await api.getSession(workspaceName, initialSessionId, agentType, MESSAGES_PER_PAGE, messageOffset)
316+
if (moreData?.messages) {
317+
const olderMessages = parseMessages(moreData.messages)
318+
setMessages(prev => [...olderMessages, ...prev])
319+
setHasMoreMessages(moreData.hasMore || false)
320+
setMessageOffset(prev => prev + moreData.messages.length)
321+
}
322+
} catch (err) {
323+
console.error('Failed to load more messages:', err)
324+
} finally {
325+
setIsLoadingMore(false)
326+
}
327+
}, [hasMoreMessages, isLoadingMore, initialSessionId, workspaceName, agentType, messageOffset, parseMessages])
296328

297329
const connect = useCallback(() => {
298330
const url = getChatUrl(workspaceName, agentType as AgentType)
@@ -373,7 +405,7 @@ export function SessionChatScreen({ route, navigation }: any) {
373405
.filter(p => p.type === 'text')
374406
.map(p => p.content)
375407
.join('')
376-
setAllMessages((prev) => [...prev, {
408+
setMessages((prev) => [...prev, {
377409
role: 'assistant',
378410
content: textContent || '',
379411
id: `msg-done-${Date.now()}`,
@@ -387,7 +419,7 @@ export function SessionChatScreen({ route, navigation }: any) {
387419
}
388420

389421
if (msg.type === 'error') {
390-
setAllMessages((prev) => [...prev, { role: 'system', content: `Error: ${msg.content || msg.message}`, id: `msg-err-${Date.now()}` }])
422+
setMessages((prev) => [...prev, { role: 'system', content: `Error: ${msg.content || msg.message}`, id: `msg-err-${Date.now()}` }])
391423
setIsStreaming(false)
392424
return
393425
}
@@ -405,7 +437,7 @@ export function SessionChatScreen({ route, navigation }: any) {
405437
ws.onerror = () => {
406438
setConnected(false)
407439
setIsStreaming(false)
408-
setAllMessages((prev) => [...prev, { role: 'system', content: 'Connection error', id: `msg-conn-err-${Date.now()}` }])
440+
setMessages((prev) => [...prev, { role: 'system', content: 'Connection error', id: `msg-conn-err-${Date.now()}` }])
409441
}
410442

411443
return () => ws.close()
@@ -416,35 +448,18 @@ export function SessionChatScreen({ route, navigation }: any) {
416448
return cleanup
417449
}, [connect])
418450

419-
useEffect(() => {
420-
if (allMessages.length > 0 && !initialScrollDone) {
421-
setTimeout(() => {
422-
flatListRef.current?.scrollToEnd({ animated: false })
423-
setInitialScrollDone(true)
424-
}, 100)
425-
}
426-
}, [allMessages, initialScrollDone])
427-
428-
const displayedMessages = useMemo(() => {
429-
if (allMessages.length <= displayCount) {
430-
return allMessages
431-
}
432-
return allMessages.slice(-displayCount)
433-
}, [allMessages, displayCount])
434-
435-
const hasMoreMessages = allMessages.length > displayCount
436-
437-
const loadMoreMessages = useCallback(() => {
438-
if (hasMoreMessages) {
439-
setDisplayCount((prev) => prev + MESSAGES_PER_PAGE)
451+
const handleScroll = useCallback((event: any) => {
452+
const { contentOffset } = event.nativeEvent
453+
if (contentOffset.y < 100 && hasMoreMessages && !isLoadingMore) {
454+
loadMoreMessages()
440455
}
441-
}, [hasMoreMessages])
456+
}, [hasMoreMessages, isLoadingMore, loadMoreMessages])
442457

443458
const sendMessage = () => {
444459
if (!input.trim() || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return
445460

446461
const msg = input.trim()
447-
setAllMessages((prev) => [...prev, { role: 'user', content: msg, id: `msg-user-${Date.now()}` }])
462+
setMessages((prev) => [...prev, { role: 'user', content: msg, id: `msg-user-${Date.now()}` }])
448463
setInput('')
449464
setIsStreaming(true)
450465
streamingPartsRef.current = []
@@ -504,15 +519,17 @@ export function SessionChatScreen({ route, navigation }: any) {
504519

505520
<FlatList
506521
ref={flatListRef}
507-
data={displayedMessages}
522+
data={messages}
508523
keyExtractor={(item) => item.id}
509524
renderItem={({ item }) => <MessageBubble message={item} />}
510525
contentContainerStyle={styles.messageList}
526+
onScroll={handleScroll}
527+
scrollEventThrottle={100}
511528
ListHeaderComponent={
512-
hasMoreMessages ? (
513-
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMoreMessages}>
514-
<Text style={styles.loadMoreText}>Load older messages</Text>
515-
</TouchableOpacity>
529+
isLoadingMore ? (
530+
<View style={styles.loadingMore}>
531+
<ActivityIndicator size="small" color="#0a84ff" />
532+
</View>
516533
) : null
517534
}
518535
ListFooterComponent={
@@ -528,6 +545,7 @@ export function SessionChatScreen({ route, navigation }: any) {
528545
) : null
529546
}
530547
onScrollToIndexFailed={() => {}}
548+
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
531549
/>
532550

533551
<View style={[styles.inputContainer, { paddingBottom: keyboardVisible ? 8 : insets.bottom + 8 }]}>
@@ -740,15 +758,9 @@ const styles = StyleSheet.create({
740758
dot3: {
741759
opacity: 0.8,
742760
},
743-
loadMoreBtn: {
761+
loadingMore: {
744762
alignItems: 'center',
745-
paddingVertical: 12,
746-
marginBottom: 8,
747-
},
748-
loadMoreText: {
749-
fontSize: 14,
750-
color: '#0a84ff',
751-
fontWeight: '500',
763+
paddingVertical: 16,
752764
},
753765
emptyChat: {
754766
flex: 1,

0 commit comments

Comments
 (0)