From a005c14a678d853266264c52880d9371382fa934 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:54:08 +0530 Subject: [PATCH] feat(api): Optimize conversation parsing with worker-based decoding --- lib/core/providers/app_providers.dart | 569 +++++++++--------- lib/core/services/api_service.dart | 40 +- .../services/optimized_storage_service.dart | 51 +- lib/core/services/worker_manager.dart | 14 +- .../chat/providers/chat_providers.dart | 98 ++- .../navigation/widgets/chats_drawer.dart | 18 + .../utils/conversation_context_menu.dart | 8 + 7 files changed, 497 insertions(+), 301 deletions(-) diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index ef4f26c..70f710f 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -58,6 +58,7 @@ final optimizedStorageServiceProvider = Provider(( return OptimizedStorageService( secureStorage: ref.watch(secureStorageProvider), boxes: ref.watch(hiveBoxesProvider), + workerManager: ref.watch(workerManagerProvider), ); }); @@ -882,311 +883,341 @@ class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp { void set(DateTime? timestamp) => state = timestamp; } -/// Clears the in-memory timestamp cache and invalidates the conversations -/// provider so the next read forces a refetch. Optionally invalidates the -/// folders provider when folder metadata must stay in sync with conversations. +/// Clears the in-memory timestamp cache and triggers a refresh of the +/// conversations provider. Optionally refreshes the folders provider so folder +/// metadata stays in sync. void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) { ref.read(_conversationsCacheTimestampProvider.notifier).set(null); - ref.invalidate(conversationsProvider); - if (includeFolders) { - ref.invalidate(foldersProvider); - } + final notifier = ref.read(conversationsProvider.notifier); + unawaited(notifier.refresh(includeFolders: includeFolders)); } -// Conversation providers - Now using correct OpenWebUI API with caching -// keepAlive to maintain cache during authenticated session +// Conversation providers - Now using correct OpenWebUI API with caching and +// immediate mutation helpers. @Riverpod(keepAlive: true) -Future> conversations(Ref ref) async { - // Do not fetch protected data until authenticated. Use watch so we refetch - // when the auth state transitions in either direction. - final authed = ref.watch(isAuthenticatedProvider2); - if (!authed) { - DebugLogger.log('skip-unauthed', scope: 'conversations'); - return []; - } - // Check if we have a recent cache (within 5 seconds) - final lastFetch = ref.read(_conversationsCacheTimestampProvider); - if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) { - DebugLogger.log( - 'cache-hit', - scope: 'conversations', - data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds}, - ); - // Note: Can't read our own provider here, would cause a cycle - // The caching is handled by Riverpod's built-in mechanism - } - final reviewerMode = ref.watch(reviewerModeProvider); - if (reviewerMode) { - // Provide a simple local demo conversation list - return [ - Conversation( - id: 'demo-conv-1', - title: 'Welcome to Conduit (Demo)', - createdAt: DateTime.now().subtract(const Duration(minutes: 15)), - updatedAt: DateTime.now().subtract(const Duration(minutes: 10)), - messages: [ - ChatMessage( - id: 'demo-msg-1', - role: 'assistant', - content: - '**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.', - timestamp: DateTime.now().subtract(const Duration(minutes: 10)), - model: 'Gemma 2 Mini (Demo)', - isStreaming: false, - ), - ], - ), - ]; - } - final api = ref.watch(apiServiceProvider); - if (api == null) { - DebugLogger.warning('api-missing', scope: 'conversations'); - return []; +class Conversations extends _$Conversations { + @override + Future> build() async { + final authed = ref.watch(isAuthenticatedProvider2); + if (!authed) { + DebugLogger.log('skip-unauthed', scope: 'conversations'); + _updateCacheTimestamp(null); + return const []; + } + + if (ref.watch(reviewerModeProvider)) { + return _demoConversations(); + } + + return _loadRemoteConversations(); } - try { - DebugLogger.log('fetch-start', scope: 'conversations'); - final conversations = await api - .getConversations(); // Fetch all conversations - DebugLogger.log( - 'fetch-ok', - scope: 'conversations', - data: {'count': conversations.length}, - ); + Future refresh({bool includeFolders = false}) async { + final authed = ref.read(isAuthenticatedProvider2); + if (!authed) { + _updateCacheTimestamp(null); + state = AsyncData>([]); + if (includeFolders) { + ref.invalidate(foldersProvider); + } + return; + } + + if (ref.read(reviewerModeProvider)) { + state = AsyncData>(_demoConversations()); + if (includeFolders) { + ref.invalidate(foldersProvider); + } + return; + } + + final result = await AsyncValue.guard(_loadRemoteConversations); + if (!ref.mounted) return; + state = result; + if (includeFolders) { + ref.invalidate(foldersProvider); + } + } + + void removeConversation(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((conversation) => conversation.id != id) + .toList(growable: true); + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + void upsertConversation(Conversation conversation) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere( + (element) => element.id == conversation.id, + ); + if (index >= 0) { + updated[index] = conversation; + } else { + updated.add(conversation); + } + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + void updateConversation( + String id, + Conversation Function(Conversation conversation) transform, + ) { + final current = state.asData?.value; + if (current == null) return; + final index = current.indexWhere((conversation) => conversation.id == id); + if (index < 0) return; + final updated = [...current]; + updated[index] = transform(updated[index]); + state = AsyncData>(_sortByUpdatedAt(updated)); + } + + List _demoConversations() => [ + Conversation( + id: 'demo-conv-1', + title: 'Welcome to Conduit (Demo)', + createdAt: DateTime.now().subtract(const Duration(minutes: 15)), + updatedAt: DateTime.now().subtract(const Duration(minutes: 10)), + messages: [ + ChatMessage( + id: 'demo-msg-1', + role: 'assistant', + content: + '**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.', + timestamp: DateTime.now().subtract(const Duration(minutes: 10)), + model: 'Gemma 2 Mini (Demo)', + isStreaming: false, + ), + ], + ), + ]; + + Future> _loadRemoteConversations() async { + final api = ref.watch(apiServiceProvider); + if (api == null) { + DebugLogger.warning('api-missing', scope: 'conversations'); + return const []; + } - // Also fetch folder information and update conversations with folder IDs try { - final foldersData = await api.getFolders(); + DebugLogger.log('fetch-start', scope: 'conversations'); + final conversations = await api.getConversations(); DebugLogger.log( - 'folders-fetched', + 'fetch-ok', scope: 'conversations', - data: {'count': foldersData.length}, + data: {'count': conversations.length}, ); - // Parse folder data into Folder objects - final folders = foldersData - .map((folderData) => Folder.fromJson(folderData)) - .toList(); - - // Create a map of conversation ID to folder ID - final conversationToFolder = {}; - for (final folder in folders) { + try { + final foldersData = await api.getFolders(); DebugLogger.log( - 'folder', - scope: 'conversations/map', - data: { - 'id': folder.id, - 'name': folder.name, - 'count': folder.conversationIds.length, - }, + 'folders-fetched', + scope: 'conversations', + data: {'count': foldersData.length}, ); - for (final conversationId in folder.conversationIds) { - conversationToFolder[conversationId] = folder.id; - DebugLogger.log( - 'map', - scope: 'conversations/map', - data: {'conversationId': conversationId, 'folderId': folder.id}, - ); - } - } - // Update conversations with folder IDs, preferring explicit folder_id from chat if present - // Use a map to ensure uniqueness by ID throughout the merge process - final conversationMap = {}; + final folders = foldersData + .map((folderData) => Folder.fromJson(folderData)) + .toList(); - for (final conversation in conversations) { - // Prefer server-provided folderId on the chat itself - final explicitFolderId = conversation.folderId; - final mappedFolderId = conversationToFolder[conversation.id]; - final folderIdToUse = explicitFolderId ?? mappedFolderId; - if (folderIdToUse != null) { - conversationMap[conversation.id] = conversation.copyWith( - folderId: folderIdToUse, - ); + final conversationToFolder = {}; + for (final folder in folders) { DebugLogger.log( - 'update-folder', + 'folder', scope: 'conversations/map', data: { - 'conversationId': conversation.id, - 'folderId': folderIdToUse, - 'explicit': explicitFolderId != null, + 'id': folder.id, + 'name': folder.name, + 'count': folder.conversationIds.length, + }, + ); + for (final conversationId in folder.conversationIds) { + conversationToFolder[conversationId] = folder.id; + DebugLogger.log( + 'map', + scope: 'conversations/map', + data: {'conversationId': conversationId, 'folderId': folder.id}, + ); + } + } + + final conversationMap = {}; + + for (final conversation in conversations) { + final explicitFolderId = conversation.folderId; + final mappedFolderId = conversationToFolder[conversation.id]; + final folderIdToUse = explicitFolderId ?? mappedFolderId; + if (folderIdToUse != null) { + conversationMap[conversation.id] = conversation.copyWith( + folderId: folderIdToUse, + ); + DebugLogger.log( + 'update-folder', + scope: 'conversations/map', + data: { + 'conversationId': conversation.id, + 'folderId': folderIdToUse, + 'explicit': explicitFolderId != null, + }, + ); + } else { + conversationMap[conversation.id] = conversation; + } + } + + final existingIds = conversationMap.keys.toSet(); + final missingInBase = conversationToFolder.keys + .where((id) => !existingIds.contains(id)) + .toList(); + if (missingInBase.isNotEmpty) { + DebugLogger.warning( + 'missing-in-base', + scope: 'conversations/map', + data: { + 'count': missingInBase.length, + 'preview': missingInBase.take(5).toList(), }, ); } else { - conversationMap[conversation.id] = conversation; + DebugLogger.log('folders-synced', scope: 'conversations/map'); } - } - // Merge conversations that are in folders but missing from the main list - // Build a set of existing IDs from the fetched list - final existingIds = conversationMap.keys.toSet(); + for (final folder in folders) { + final missingIds = folder.conversationIds + .where((id) => !existingIds.contains(id)) + .toList(); - // Diagnostics: count how many folder-mapped IDs are missing from the main list - final missingInBase = conversationToFolder.keys - .where((id) => !existingIds.contains(id)) - .toList(); - if (missingInBase.isNotEmpty) { - DebugLogger.warning( - 'missing-in-base', - scope: 'conversations/map', - data: { - 'count': missingInBase.length, - 'preview': missingInBase.take(5).toList(), - }, + final hasKnownConversations = conversationMap.values.any( + (conversation) => conversation.folderId == folder.id, + ); + + final shouldFetchFolder = + missingIds.isNotEmpty || + (!hasKnownConversations && folder.conversationIds.isEmpty); + + List folderConvs = const []; + if (shouldFetchFolder) { + try { + folderConvs = await api.getConversationsInFolder(folder.id); + DebugLogger.log( + 'folder-sync', + scope: 'conversations/map', + data: { + 'folderId': folder.id, + 'fetched': folderConvs.length, + 'missingIds': missingIds.length, + }, + ); + } catch (e) { + DebugLogger.error( + 'folder-fetch-failed', + scope: 'conversations/map', + error: e, + data: {'folderId': folder.id}, + ); + } + } + + final fetchedMap = {for (final c in folderConvs) c.id: c}; + + for (final convId in missingIds) { + final fetched = fetchedMap[convId]; + if (fetched != null) { + final toAdd = fetched.folderId == null + ? fetched.copyWith(folderId: folder.id) + : fetched; + conversationMap[toAdd.id] = toAdd; + existingIds.add(toAdd.id); + DebugLogger.log( + 'add-missing', + scope: 'conversations/map', + data: {'conversationId': toAdd.id, 'folderId': folder.id}, + ); + } else { + final placeholder = Conversation( + id: convId, + title: 'Chat', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: const [], + folderId: folder.id, + ); + conversationMap[convId] = placeholder; + existingIds.add(convId); + DebugLogger.log( + 'add-placeholder', + scope: 'conversations/map', + data: {'conversationId': convId, 'folderId': folder.id}, + ); + } + } + + if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) { + for (final conv in folderConvs) { + final toAdd = conv.folderId == null + ? conv.copyWith(folderId: folder.id) + : conv; + conversationMap[toAdd.id] = toAdd; + existingIds.add(toAdd.id); + DebugLogger.log( + 'add-folder-fetch', + scope: 'conversations/map', + data: {'conversationId': toAdd.id, 'folderId': folder.id}, + ); + } + } + } + + final sortedConversations = _sortByUpdatedAt( + conversationMap.values.toList(), ); - } else { - DebugLogger.log('folders-synced', scope: 'conversations/map'); - } - - // Attempt to fetch missing conversations per-folder to construct accurate entries - // If per-folder fetch fails, fall back to creating minimal placeholder entries - final apiSvc = ref.read(apiServiceProvider); - for (final folder in folders) { - // Collect IDs in this folder that are missing - final missingIds = folder.conversationIds - .where((id) => !existingIds.contains(id)) - .toList(); - - final hasKnownConversations = conversationMap.values.any( - (conversation) => conversation.folderId == folder.id, + DebugLogger.log( + 'sort', + scope: 'conversations', + data: {'source': 'folder-sync'}, ); - - final shouldFetchFolder = - apiSvc != null && - (missingIds.isNotEmpty || - (!hasKnownConversations && folder.conversationIds.isEmpty)); - - List folderConvs = const []; - if (shouldFetchFolder) { - try { - folderConvs = await apiSvc.getConversationsInFolder(folder.id); - DebugLogger.log( - 'folder-sync', - scope: 'conversations/map', - data: { - 'folderId': folder.id, - 'fetched': folderConvs.length, - 'missingIds': missingIds.length, - }, - ); - } catch (e) { - DebugLogger.error( - 'folder-fetch-failed', - scope: 'conversations/map', - error: e, - data: {'folderId': folder.id}, - ); - } - } - - // Index fetched folder conversations for quick lookup - final fetchedMap = {for (final c in folderConvs) c.id: c}; - - for (final convId in missingIds) { - final fetched = fetchedMap[convId]; - if (fetched != null) { - final toAdd = fetched.folderId == null - ? fetched.copyWith(folderId: folder.id) - : fetched; - // Use map to prevent duplicates - this will overwrite if ID already exists - conversationMap[toAdd.id] = toAdd; - existingIds.add(toAdd.id); - DebugLogger.log( - 'add-missing', - scope: 'conversations/map', - data: {'conversationId': toAdd.id, 'folderId': folder.id}, - ); - } else { - // Create a minimal placeholder if not returned by folder API - final placeholder = Conversation( - id: convId, - title: 'Chat', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - messages: const [], - folderId: folder.id, - ); - // Use map to prevent duplicates - conversationMap[convId] = placeholder; - existingIds.add(convId); - DebugLogger.log( - 'add-placeholder', - scope: 'conversations/map', - data: {'conversationId': convId, 'folderId': folder.id}, - ); - } - } - - if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) { - for (final conv in folderConvs) { - final toAdd = conv.folderId == null - ? conv.copyWith(folderId: folder.id) - : conv; - conversationMap[toAdd.id] = toAdd; - existingIds.add(toAdd.id); - DebugLogger.log( - 'add-folder-fetch', - scope: 'conversations/map', - data: {'conversationId': toAdd.id, 'folderId': folder.id}, - ); - } - } + _updateCacheTimestamp(DateTime.now()); + return sortedConversations; + } catch (e) { + DebugLogger.error( + 'folders-fetch-failed', + scope: 'conversations', + error: e, + ); + final sorted = _sortByUpdatedAt(conversations.toList()); + DebugLogger.log( + 'sort', + scope: 'conversations', + data: {'source': 'fallback'}, + ); + _updateCacheTimestamp(DateTime.now()); + return sorted; } - - // Convert map back to list - this ensures no duplicates by ID - final sortedConversations = conversationMap.values.toList(); - - // Sort conversations by updatedAt in descending order (most recent first) - sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - DebugLogger.log( - 'sort', - scope: 'conversations', - data: {'source': 'folder-sync'}, - ); - - // Update cache timestamp - ref - .read(_conversationsCacheTimestampProvider.notifier) - .set(DateTime.now()); - - return sortedConversations; - } catch (e) { + } catch (e, stackTrace) { DebugLogger.error( - 'folders-fetch-failed', + 'fetch-failed', scope: 'conversations', error: e, + stackTrace: stackTrace, ); - // Sort conversations even when folder fetch fails - conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - DebugLogger.log( - 'sort', - scope: 'conversations', - data: {'source': 'fallback'}, - ); - - // Update cache timestamp - ref - .read(_conversationsCacheTimestampProvider.notifier) - .set(DateTime.now()); - - return conversations; // Return original conversations if folder fetch fails + if (e.toString().contains('403')) { + DebugLogger.warning('endpoint-403', scope: 'conversations'); + } + return const []; } - } catch (e, stackTrace) { - DebugLogger.error( - 'fetch-failed', - scope: 'conversations', - error: e, - stackTrace: stackTrace, - ); + } - // If conversations endpoint returns 403, this should now clear auth token - // and redirect user to login since it's marked as a core endpoint - if (e.toString().contains('403')) { - DebugLogger.warning('endpoint-403', scope: 'conversations'); - } + List _sortByUpdatedAt(List conversations) { + final sorted = [...conversations]; + sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return List.unmodifiable(sorted); + } - // Return empty list instead of re-throwing to allow app to continue functioning - return []; + void _updateCacheTimestamp(DateTime? timestamp) { + ref.read(_conversationsCacheTimestampProvider.notifier).set(timestamp); } } diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 34b0c83..b8ab4c8 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -911,6 +911,26 @@ class ApiService { return []; } + Future> _parseConversationSummaryList( + List regular, { + required String debugLabel, + }) async { + final payload = { + 'regular': List.from(regular), + 'pinned': const [], + 'archived': const [], + }; + final parsed = await _workerManager + .schedule, List>>( + parseConversationSummariesWorker, + payload, + debugLabel: debugLabel, + ); + return parsed + .map((json) => Conversation.fromJson(json)) + .toList(growable: false); + } + // Tools - Check available tools on server Future>> getAvailableTools() async { _traceApi('Fetching available tools'); @@ -1005,10 +1025,10 @@ class ApiService { final response = await _dio.get('/api/v1/chats/folder/$folderId'); final data = response.data; if (data is List) { - return data.whereType().map((chatData) { - final map = Map.from(chatData); - return Conversation.fromJson(parseConversationSummary(map)); - }).toList(); + return _parseConversationSummaryList( + data, + debugLabel: 'parse_folder_$folderId', + ); } return []; } @@ -1052,10 +1072,7 @@ class ApiService { final response = await _dio.get('/api/v1/chats/tags/$tag'); final data = response.data; if (data is List) { - return data.whereType().map((chatData) { - final map = Map.from(chatData); - return Conversation.fromJson(parseConversationSummary(map)); - }).toList(); + return _parseConversationSummaryList(data, debugLabel: 'parse_tag_$tag'); } return []; } @@ -2738,8 +2755,11 @@ class ApiService { '/api/v1/chats/search', queryParameters: {'q': query}, ); - final results = response.data as List; - return results.map((c) => Conversation.fromJson(c)).toList(); + final results = response.data; + if (results is List) { + return _parseConversationSummaryList(results, debugLabel: 'parse_search'); + } + return []; } // Debug method to test API endpoints diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index fa22197..f15327c 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import '../utils/debug_logger.dart'; import 'secure_credential_storage.dart'; +import 'worker_manager.dart'; /// Optimized storage service backed by Hive for non-sensitive data and /// FlutterSecureStorage for credentials. @@ -16,19 +17,22 @@ class OptimizedStorageService { OptimizedStorageService({ required FlutterSecureStorage secureStorage, required HiveBoxes boxes, + required WorkerManager workerManager, }) : _preferencesBox = boxes.preferences, _cachesBox = boxes.caches, _attachmentQueueBox = boxes.attachmentQueue, _metadataBox = boxes.metadata, _secureCredentialStorage = SecureCredentialStorage( instance: secureStorage, - ); + ), + _workerManager = workerManager; final Box _preferencesBox; final Box _cachesBox; final Box _attachmentQueueBox; final Box _metadataBox; final SecureCredentialStorage _secureCredentialStorage; + final WorkerManager _workerManager; static const String _authTokenKey = 'auth_token_v3'; static const String _activeServerIdKey = PreferenceKeys.activeServerId; @@ -298,19 +302,13 @@ class OptimizedStorageService { if (stored == null) { return const []; } - if (stored is String) { - final decoded = jsonDecode(stored) as List; - return decoded.map((item) => Conversation.fromJson(item)).toList(); - } - if (stored is List) { - return stored - .map( - (item) => - Conversation.fromJson(Map.from(item as Map)), - ) - .toList(); - } - return const []; + final parsed = await _workerManager + .schedule, List>>( + _decodeStoredConversationsWorker, + {'stored': stored}, + debugLabel: 'decode_local_conversations', + ); + return parsed.map(Conversation.fromJson).toList(growable: false); } catch (error, stack) { DebugLogger.error( 'Failed to retrieve local conversations', @@ -455,3 +453,28 @@ class OptimizedStorageService { }; } } + +List> _decodeStoredConversationsWorker( + Map payload, +) { + final stored = payload['stored']; + if (stored is String) { + final decoded = jsonDecode(stored); + if (decoded is List) { + return decoded + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + return >[]; + } + + if (stored is List) { + return stored + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + + return >[]; +} diff --git a/lib/core/services/worker_manager.dart b/lib/core/services/worker_manager.dart index a972524..3d095fa 100644 --- a/lib/core/services/worker_manager.dart +++ b/lib/core/services/worker_manager.dart @@ -170,12 +170,14 @@ class WorkerManager { /// Keep a single [WorkerManager] alive across the app. @Riverpod(keepAlive: true) -// ignore: functional_ref -WorkerManager workerManager(Ref ref) { - final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks; - final manager = WorkerManager(maxConcurrentTasks: concurrency); - ref.onDispose(manager.dispose); - return manager; +class WorkerManagerNotifier extends _$WorkerManagerNotifier { + @override + WorkerManager build() { + final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks; + final manager = WorkerManager(maxConcurrentTasks: concurrency); + ref.onDispose(manager.dispose); + return manager; + } } class _EnqueuedJob { diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 2d1372c..cea62f7 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -719,9 +719,40 @@ class ChatMessagesNotifier extends Notifier> { _messageStream = null; _stopRemoteTaskMonitor(); + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation != null) { + final updatedActive = activeConversation.copyWith( + messages: List.unmodifiable(state), + updatedAt: DateTime.now(), + ); + ref.read(activeConversationProvider.notifier).set(updatedActive); + + final conversationsAsync = ref.read(conversationsProvider); + Conversation? summary; + conversationsAsync.maybeWhen( + data: (conversations) { + for (final conversation in conversations) { + if (conversation.id == updatedActive.id) { + summary = conversation; + break; + } + } + }, + orElse: () {}, + ); + final updatedSummary = + (summary ?? updatedActive.copyWith(messages: const [])).copyWith( + updatedAt: updatedActive.updatedAt, + ); + + ref + .read(conversationsProvider.notifier) + .upsertConversation(updatedSummary.copyWith(messages: const [])); + } + // Trigger a refresh of the conversations list so UI like the Chats Drawer - // can pick up updated titles and ordering once streaming completes. - // Best-effort: ignore if ref lifecycle/context prevents invalidation. + // can reconcile with the server once streaming completes. Best-effort: + // ignore if ref lifecycle/context prevents invalidation. try { refreshConversationsCache(ref); } catch (_) {} @@ -1480,6 +1511,15 @@ Future regenerateMessage( ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); + ref + .read(conversationsProvider.notifier) + .updateConversation( + active.id, + (conversation) => conversation.copyWith( + title: newTitle, + updatedAt: DateTime.now(), + ), + ); } refreshConversationsCache(ref); }, @@ -1492,6 +1532,9 @@ Future regenerateMessage( try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); + ref + .read(conversationsProvider.notifier) + .upsertConversation(refreshed.copyWith(messages: const [])); } catch (_) {} }); } @@ -1625,6 +1668,12 @@ Future _sendMessageInternal( ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + ref + .read(conversationsProvider.notifier) + .upsertConversation( + updatedConversation.copyWith(updatedAt: DateTime.now()), + ); + // Invalidate conversations provider to refresh the list // Adding a small delay to prevent rapid invalidations that could cause duplicates Future.delayed(const Duration(milliseconds: 100), () { @@ -2029,6 +2078,15 @@ Future _sendMessageInternal( ref .read(activeConversationProvider.notifier) .set(active.copyWith(title: newTitle)); + ref + .read(conversationsProvider.notifier) + .updateConversation( + active.id, + (conversation) => conversation.copyWith( + title: newTitle, + updatedAt: DateTime.now(), + ), + ); } refreshConversationsCache(ref); }, @@ -2041,6 +2099,9 @@ Future _sendMessageInternal( try { final refreshed = await api.getConversation(active.id); ref.read(activeConversationProvider.notifier).set(refreshed); + ref + .read(conversationsProvider.notifier) + .upsertConversation(refreshed.copyWith(messages: const [])); } catch (_) {} }); } @@ -2204,6 +2265,14 @@ Future pinConversation( await api.pinConversation(conversationId, pinned); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => + conversation.copyWith(pinned: pinned, updatedAt: DateTime.now()), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); @@ -2243,6 +2312,16 @@ Future archiveConversation( await api.archiveConversation(conversationId, archived); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => conversation.copyWith( + archived: archived, + updatedAt: DateTime.now(), + ), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); } catch (e) { @@ -2269,6 +2348,16 @@ Future shareConversation(WidgetRef ref, String conversationId) async { final shareId = await api.shareConversation(conversationId); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => conversation.copyWith( + shareId: shareId, + updatedAt: DateTime.now(), + ), + ); + // Refresh conversations list to reflect the change refreshConversationsCache(ref); @@ -2293,6 +2382,11 @@ Future cloneConversation(WidgetRef ref, String conversationId) async { // The ChatMessagesNotifier will automatically load messages when activeConversation changes // Refresh conversations list to show the new conversation + ref + .read(conversationsProvider.notifier) + .upsertConversation( + clonedConversation.copyWith(updatedAt: DateTime.now()), + ); refreshConversationsCache(ref); } catch (e) { DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers'); diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 33d36c9..29ccfb9 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -813,6 +813,15 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.moveConversationToFolder(details.data.id, folderId); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + details.data.id, + (conversation) => conversation.copyWith( + folderId: folderId, + updatedAt: DateTime.now(), + ), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { DebugLogger.error( @@ -1153,6 +1162,15 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.moveConversationToFolder(details.data.id, null); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + details.data.id, + (conversation) => conversation.copyWith( + folderId: null, + updatedAt: DateTime.now(), + ), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { DebugLogger.error( diff --git a/lib/shared/utils/conversation_context_menu.dart b/lib/shared/utils/conversation_context_menu.dart index 893a1e1..45b1a24 100644 --- a/lib/shared/utils/conversation_context_menu.dart +++ b/lib/shared/utils/conversation_context_menu.dart @@ -221,6 +221,13 @@ Future _renameConversation( if (api == null) throw Exception('No API service'); await api.updateConversation(conversationId, title: newName); HapticFeedback.selectionClick(); + ref + .read(conversationsProvider.notifier) + .updateConversation( + conversationId, + (conversation) => + conversation.copyWith(title: newName, updatedAt: DateTime.now()), + ); refreshConversationsCache(ref); final active = ref.read(activeConversationProvider); if (active?.id == conversationId) { @@ -257,6 +264,7 @@ Future _confirmAndDeleteConversation( if (api == null) throw Exception('No API service'); await api.deleteConversation(conversationId); HapticFeedback.mediumImpact(); + ref.read(conversationsProvider.notifier).removeConversation(conversationId); final active = ref.read(activeConversationProvider); if (active?.id == conversationId) { ref.read(activeConversationProvider.notifier).clear();