From 2675242df3a88db20299d544147b56f73e3bad65 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:08:55 +0000 Subject: [PATCH 1/2] Persist chat and image history with AsyncStorage - Chat history (per-model conversation states) saved to AsyncStorage on every update - Image generation history saved to AsyncStorage after each successful generation - Both histories restored on component mount - Clear chat/clear prompts properly cleans up persisted data Co-Authored-By: Nader Dabit --- app/src/screens/chat.tsx | 47 ++++++++++++++++++++++++++++++++----- app/src/screens/images.tsx | 48 +++++++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/app/src/screens/chat.tsx b/app/src/screens/chat.tsx index 13deec5b..65de39e0 100644 --- a/app/src/screens/chat.tsx +++ b/app/src/screens/chat.tsx @@ -11,7 +11,8 @@ import { Keyboard } from 'react-native' import 'react-native-get-random-values' -import { useContext, useState, useRef } from 'react' +import { useContext, useState, useRef, useEffect, useCallback } from 'react' +import AsyncStorage from '@react-native-async-storage/async-storage' import { ThemeContext, AppContext } from '../context' import { getEventSource, getFirstNCharsOrLess, getChatType } from '../utils' import { v4 as uuid } from 'uuid' @@ -40,6 +41,31 @@ export function Chat() { // Per-model chat state - each model has its own conversation history const [chatStates, setChatStates] = useState>({}) + const [chatLoaded, setChatLoaded] = useState(false) + + // Load persisted chat history on mount + useEffect(() => { + async function loadChatHistory() { + try { + const stored = await AsyncStorage.getItem('rnai-chatStates') + if (stored) { + setChatStates(JSON.parse(stored)) + } + } catch (err) { + console.log('error loading chat history', err) + } finally { + setChatLoaded(true) + } + } + loadChatHistory() + }, []) + + // Persist chat history when it changes + const saveChatHistory = useCallback((states: Record) => { + AsyncStorage.setItem('rnai-chatStates', JSON.stringify(states)).catch(err => + console.log('error saving chat history', err) + ) + }, []) // Helper to get or create chat state for current model const getChatState = (modelLabel: string): ChatState => { @@ -48,10 +74,14 @@ export function Chat() { // Helper to update chat state for a specific model const updateChatState = (modelLabel: string, updater: (prev: ChatState) => ChatState) => { - setChatStates(prev => ({ - ...prev, - [modelLabel]: updater(prev[modelLabel] || createEmptyChatState()) - })) + setChatStates(prev => { + const next = { + ...prev, + [modelLabel]: updater(prev[modelLabel] || createEmptyChatState()) + } + saveChatHistory(next) + return next + }) } const { theme } = useContext(ThemeContext) @@ -329,7 +359,12 @@ export function Chat() { async function clearChat() { if (loading) return const modelLabel = chatType.label - updateChatState(modelLabel, () => createEmptyChatState()) + setChatStates(prev => { + const next = { ...prev } + delete next[modelLabel] + saveChatHistory(next) + return next + }) } function renderItem({ diff --git a/app/src/screens/images.tsx b/app/src/screens/images.tsx index cb2abce2..d3e3e0b1 100644 --- a/app/src/screens/images.tsx +++ b/app/src/screens/images.tsx @@ -11,7 +11,7 @@ import { Keyboard, Image } from 'react-native' -import { useState, useRef, useContext } from 'react' +import { useState, useRef, useContext, useEffect, useCallback } from 'react' import { DOMAIN, IMAGE_MODELS } from '../../constants' import { v4 as uuid } from 'uuid' import { ThemeContext, AppContext } from '../context' @@ -21,6 +21,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet' import * as FileSystem from 'expo-file-system' import * as ImagePicker from 'expo-image-picker' import * as Clipboard from 'expo-clipboard' +import AsyncStorage from '@react-native-async-storage/async-storage' const { width } = Dimensions.get('window') @@ -41,6 +42,35 @@ export function Images() { index: uuid, values: [] }) + const [imagesLoaded, setImagesLoaded] = useState(false) + + // Load persisted image history on mount + useEffect(() => { + async function loadImageHistory() { + try { + const stored = await AsyncStorage.getItem('rnai-imageHistory') + if (stored) { + const parsed = JSON.parse(stored) + setImages(parsed) + if (parsed.values.length > 0) { + setCallMade(true) + } + } + } catch (err) { + console.log('error loading image history', err) + } finally { + setImagesLoaded(true) + } + } + loadImageHistory() + }, []) + + // Persist image history when it changes + const saveImageHistory = useCallback((state: ImagesState) => { + AsyncStorage.setItem('rnai-imageHistory', JSON.stringify(state)).catch(err => + console.log('error saving image history', err) + ) + }, []) const { handlePresentModalPress, closeModal, @@ -128,10 +158,12 @@ export function Images() { imagesArray[imagesArray.length - 1].image = response.image imagesArray[imagesArray.length - 1].model = currentModel imagesArray[imagesArray.length - 1].provider = providerLabel - setImages(i => ({ - index: i.index, + const newState = { + index: images.index, values: imagesArray - })) + } + setImages(newState) + saveImageHistory(newState) setLoading(false) setTimeout(() => { scrollViewRef.current?.scrollToEnd({ @@ -177,10 +209,14 @@ export function Images() { function clearPrompts() { setCallMade(false) - setImages({ + const emptyState = { index: uuid, values: [] - }) + } + setImages(emptyState) + AsyncStorage.removeItem('rnai-imageHistory').catch(err => + console.log('error clearing image history', err) + ) } async function showClipboardActionsheet(d) { From 1d1c66874ddd94bd3eded92aa6d6362657de6ef2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:13:52 +0000 Subject: [PATCH 2/2] Fix: only persist chat at stream completion, guard UI with loaded flags - Move saveChatHistory out of updateChatState to avoid writing on every SSE token - Only persist at stream completion ([DONE]) and on clear - Guard chat() and generate() with chatLoaded/imagesLoaded to prevent race conditions Co-Authored-By: Nader Dabit --- app/src/screens/chat.tsx | 23 +++++++++++++++-------- app/src/screens/images.tsx | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/src/screens/chat.tsx b/app/src/screens/chat.tsx index 65de39e0..7d28249a 100644 --- a/app/src/screens/chat.tsx +++ b/app/src/screens/chat.tsx @@ -74,13 +74,17 @@ export function Chat() { // Helper to update chat state for a specific model const updateChatState = (modelLabel: string, updater: (prev: ChatState) => ChatState) => { - setChatStates(prev => { - const next = { - ...prev, - [modelLabel]: updater(prev[modelLabel] || createEmptyChatState()) - } - saveChatHistory(next) - return next + setChatStates(prev => ({ + ...prev, + [modelLabel]: updater(prev[modelLabel] || createEmptyChatState()) + })) + } + + // Persist the current chat states to AsyncStorage + const persistChatStates = () => { + setChatStates(current => { + saveChatHistory(current) + return current }) } @@ -89,7 +93,7 @@ export function Chat() { const styles = getStyles(theme) async function chat() { - if (!input) return + if (!input || !chatLoaded) return Keyboard.dismiss() if (chatType.label.includes('claude')) { generateClaudeResponse() @@ -167,6 +171,7 @@ export function Chat() { })) } else { setLoading(false) + persistChatStates() es.close() } } else if (event.type === "error") { @@ -245,6 +250,7 @@ export function Chat() { ...prev, apiMessages: `${prev.apiMessages}\n\nPrompt: ${input}\n\nResponse:${localResponse}` })) + persistChatStates() es.close() } } else if (event.type === "error") { @@ -322,6 +328,7 @@ export function Chat() { ...prev, apiMessages: `${prev.apiMessages}\n\nHuman: ${input}\n\nAssistant:${getFirstNCharsOrLess(localResponse, 2000)}` })) + persistChatStates() es.close() } } else if (event.type === "error") { diff --git a/app/src/screens/images.tsx b/app/src/screens/images.tsx index d3e3e0b1..3f5acacc 100644 --- a/app/src/screens/images.tsx +++ b/app/src/screens/images.tsx @@ -86,7 +86,7 @@ export function Images() { const showImagePickerButton = !hideInput async function generate() { - if (loading) return + if (loading || !imagesLoaded) return if (hideInput && !image) { console.log('no image selected') return