Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/fix-chat-state-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@darkresearch/mallory-client": patch
---

Fix chat state persistence and prevent duplicate conversation creation on tab switch. Prevents infinite chat creation when navigating between modals on mobile web.

35 changes: 34 additions & 1 deletion apps/client/app/(main)/chat-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,17 +467,50 @@ export default function ChatHistoryScreen() {

setIsCreatingChat(true);

// Add a timeout guard to ensure loading state is reset even if operation hangs
const timeoutId = setTimeout(() => {
console.warn('⚠️ Chat creation taking too long, resetting loading state');
setIsCreatingChat(false);
}, 15000); // 15 second timeout

try {
const conversationData = await createNewConversation(user?.id);
// Add timeout wrapper around createNewConversation
const conversationData = await Promise.race([
createNewConversation(user?.id),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Chat creation timeout')), 10000)
)
]);

clearTimeout(timeoutId);

// Manually add conversation to list immediately (real-time subscriptions are failing)
// This ensures it appears instantly even if real-time subscriptions are broken
const now = new Date().toISOString();
const newConversation = {
id: conversationData.conversationId,
title: 'mallory-global',
token_ca: GLOBAL_TOKEN_ID,
created_at: now,
updated_at: now,
metadata: {}
};

console.log('✅ [handleNewChat] Manually adding conversation to list:', newConversation.id);
handleConversationInsert(newConversation);

setCurrentConversationId(conversationData.conversationId);
router.push(`/(main)/chat?conversationId=${conversationData.conversationId}`);

// Reset loading state after navigation
setTimeout(() => {
setIsCreatingChat(false);
}, 100);
} catch (error) {
clearTimeout(timeoutId);
console.error('Error creating new chat:', error);
setIsCreatingChat(false);
// Optionally show an error message to the user
}
};

Expand Down
56 changes: 44 additions & 12 deletions apps/client/components/chat/ChatManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,22 @@ export function ChatManager({}: ChatManagerProps) {
useEffect(() => {
const previousId = previousConversationIdRef.current;

if (previousId && previousId !== currentConversationId) {
// Only treat as actual change if both IDs are valid and different
const previousWasValid = previousId && previousId !== 'temp-loading';
const currentIsValid = currentConversationId && currentConversationId !== 'temp-loading';
const isActualChange = previousWasValid && currentIsValid && previousId !== currentConversationId;

if (isActualChange) {
console.log('🔄 [ChatManager] Conversation changed:', { from: previousId, to: currentConversationId });
console.log('🛑 [ChatManager] Stopping previous conversation stream');
stop();
clearChatCache();
}

previousConversationIdRef.current = currentConversationId;
// Only update ref with valid IDs (preserve previous during loading)
if (currentConversationId && currentConversationId !== 'temp-loading') {
previousConversationIdRef.current = currentConversationId;
}
}, [currentConversationId, stop]);

// Load historical messages when conversation ID changes
Expand All @@ -143,16 +151,40 @@ export function ChatManager({}: ChatManagerProps) {
return;
}

// Clear messages immediately when conversation changes to prevent showing old messages
console.log('🧹 [ChatManager] Clearing messages for conversation switch to:', currentConversationId);
console.log(' Before clear - messages.length:', messages.length);
console.log(' Before clear - initialMessages.length:', initialMessages.length);
setMessages([]);
setInitialMessages([]);
setInitialMessagesConversationId(null); // Track what conversation initialMessages belong to
conversationMessagesSetRef.current = null; // Reset ref so new messages can be set
console.log('✅ [ChatManager] Messages cleared (setMessages([]) and setInitialMessages([]) called)');
console.log('✅ [ChatManager] Ref and conversationId tracker reset');
const previousId = previousConversationIdRef.current;
const previousWasValid = previousId && previousId !== 'temp-loading';
const currentIsValid = currentConversationId && currentConversationId !== 'temp-loading';
const isActualChange = previousWasValid && currentIsValid && previousId !== currentConversationId;
const isSameConversation = previousId === currentConversationId;

// Check if we already loaded messages for this conversation
const alreadyLoaded = conversationMessagesSetRef.current === currentConversationId;
const hasMessages = messages.length > 0 || initialMessages.length > 0;

// Only clear messages if this is an ACTUAL conversation change (not loading state)
if (isActualChange) {
console.log('🧹 [ChatManager] Clearing messages for actual conversation change to:', currentConversationId);
console.log(' Before clear - messages.length:', messages.length);
console.log(' Before clear - initialMessages.length:', initialMessages.length);
setMessages([]);
setInitialMessages([]);
setInitialMessagesConversationId(null);
conversationMessagesSetRef.current = null;
console.log('✅ [ChatManager] Messages cleared for actual conversation change');
} else if (isSameConversation && alreadyLoaded && !hasMessages) {
// Same conversation but no messages (they were cleared) - reset to allow reload
console.log('⚠️ [ChatManager] Same conversation but no messages - resetting to allow reload');
conversationMessagesSetRef.current = null;
setInitialMessagesConversationId(null);
} else if (isSameConversation && alreadyLoaded && hasMessages) {
// Same conversation with messages already loaded - skip reload
console.log('⏭️ [ChatManager] Same conversation with messages - preserving (skipping reload)');
setIsLoadingHistory(false);
updateChatCache({ isLoadingHistory: false });
return; // Exit early, don't reload
} else {
console.log('⏭️ [ChatManager] Loading state or initial load - will load messages');
}

let isCancelled = false;

Expand Down
23 changes: 21 additions & 2 deletions apps/client/components/navigation/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { usePathname, useRouter } from 'expo-router';
import { storage, SECURE_STORAGE_KEYS } from '../../lib/storage';

type TabBarProps = {
state?: any;
Expand All @@ -20,8 +21,24 @@ export function TabBar({ state, descriptors, navigation }: TabBarProps) {
{ name: 'account', icon: 'person', route: '/(main)/account' },
];

const handlePress = (route: string) => {
const handlePress = async (route: string) => {
if (Platform.OS === 'web') {
// Preserve conversationId when navigating to chat tab
if (route === '/(main)/chat') {
try {
const currentConversationId = await storage.persistent.getItem(
SECURE_STORAGE_KEYS.CURRENT_CONVERSATION_ID
);
const url = currentConversationId
? `/(main)/chat?conversationId=${currentConversationId}`
: '/(main)/chat';
router.push(url as any);
return;
} catch (error) {
console.warn('Could not read conversation ID from storage:', error);
// Fallback to regular route if storage read fails
}
}
router.push(route as any);
} else if (navigation) {
navigation.navigate(route);
Expand All @@ -37,7 +54,9 @@ export function TabBar({ state, descriptors, navigation }: TabBarProps) {
<TouchableOpacity
key={tab.name}
style={styles.tab}
onPress={() => handlePress(tab.route)}
onPress={() => handlePress(tab.route).catch((error) => {
console.error('Navigation error:', error);
})}
>
<Ionicons
name={tab.icon as any}
Expand Down
Loading