diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 6694e1d..2248b6e 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -853,6 +853,21 @@ class SelectedModel extends _$SelectedModel { void clear() => state = null; } +/// Tracks a pending folder ID for the next new conversation. +/// +/// When a user starts a new chat from within a folder context menu, +/// this provider holds the folder ID so that the conversation is +/// automatically placed in that folder upon creation. +@Riverpod(keepAlive: true) +class PendingFolderId extends _$PendingFolderId { + @override + String? build() => null; + + void set(String? folderId) => state = folderId; + + void clear() => state = null; +} + // Track if the current model selection is manual (user-selected) or automatic (default) @Riverpod(keepAlive: true) class IsManualModelSelection extends _$IsManualModelSelection { diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 274f5f7..dc3f31d 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -812,6 +812,7 @@ class ApiService { required List messages, String? model, String? systemPrompt, + String? folderId, }) async { _traceApi('Creating new conversation on OpenWebUI server'); _traceApi('Title: $title, Messages: ${messages.length}'); @@ -893,7 +894,7 @@ class ApiService { 'tags': [], 'timestamp': DateTime.now().millisecondsSinceEpoch, }, - 'folder_id': null, + 'folder_id': folderId, }; _traceApi('Sending chat data with proper parent-child structure'); diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 9a14e5f..2c9d098 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -981,6 +981,9 @@ void startNewChat(dynamic ref) { // Clear context attachments (web pages, YouTube, knowledge base docs) ref.read(contextAttachmentsProvider.notifier).clear(); + + // Clear any pending folder selection + ref.read(pendingFolderIdProvider.notifier).clear(); } // Available tools provider @@ -1892,6 +1895,9 @@ Future _sendMessageInternal( ); if (activeConversation == null) { + // Check if there's a pending folder ID for this new conversation + final pendingFolderId = ref.read(pendingFolderIdProvider); + // Create new conversation with the first message included final localConversation = Conversation( id: const Uuid().v4(), @@ -1900,6 +1906,7 @@ Future _sendMessageInternal( updatedAt: DateTime.now(), systemPrompt: userSystemPrompt, messages: [userMessage], // Include the user message + folderId: pendingFolderId, ); // Set as active conversation locally @@ -1914,13 +1921,19 @@ Future _sendMessageInternal( messages: [userMessage], // Include the first message in creation model: selectedModel.id, systemPrompt: userSystemPrompt, + folderId: pendingFolderId, ); + + // Clear the pending folder ID after successful creation + ref.read(pendingFolderIdProvider.notifier).clear(); + final updatedConversation = localConversation.copyWith( id: serverConversation.id, systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt, messages: serverConversation.messages.isNotEmpty ? serverConversation.messages : [userMessage], + folderId: serverConversation.folderId ?? pendingFolderId, ); ref.read(activeConversationProvider.notifier).set(updatedConversation); activeConversation = updatedConversation; @@ -1945,7 +1958,10 @@ Future _sendMessageInternal( // handle any disposal gracefully. final isMounted = ref is Ref ? ref.mounted : true; if (isMounted) { - refreshConversationsCache(ref); + refreshConversationsCache( + ref, + includeFolders: pendingFolderId != null, + ); } } catch (_) { // If ref is disposed or invalid, skip @@ -1954,10 +1970,16 @@ Future _sendMessageInternal( } catch (e) { // Still add the message locally ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + + // Clear the pending folder ID on failure to prevent stale state + ref.read(pendingFolderIdProvider.notifier).clear(); } } else { // Add message for reviewer mode ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + + // Clear the pending folder ID even in reviewer mode + ref.read(pendingFolderIdProvider.notifier).clear(); } } else { // Add user message to existing conversation @@ -2490,7 +2512,8 @@ Future _sendMessageInternal( timestamp: DateTime.now(), isStreaming: false, error: const ChatMessageError( - content: 'There was an issue with the message format. This might be ' + content: + 'There was an issue with the message format. This might be ' 'because the image attachment couldn\'t be processed, the request ' 'format is incompatible with the selected model, or the message ' 'contains unsupported content. Please try sending the message ' @@ -2509,7 +2532,8 @@ Future _sendMessageInternal( timestamp: DateTime.now(), isStreaming: false, error: const ChatMessageError( - content: 'Unable to connect to the AI model. The server returned an ' + content: + 'Unable to connect to the AI model. The server returned an ' 'error (500). This is typically a server-side issue. Please try ' 'again or contact your administrator.', ), @@ -2527,7 +2551,8 @@ Future _sendMessageInternal( timestamp: DateTime.now(), isStreaming: false, error: const ChatMessageError( - content: 'The selected AI model doesn\'t seem to be available. ' + content: + 'The selected AI model doesn\'t seem to be available. ' 'Please try selecting a different model or check with your ' 'administrator.', ), @@ -2542,7 +2567,8 @@ Future _sendMessageInternal( timestamp: DateTime.now(), isStreaming: false, error: const ChatMessageError( - content: 'An unexpected error occurred while processing your request. ' + content: + 'An unexpected error occurred while processing your request. ' 'Please try again or check your connection.', ), ); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 917c1d4..9bbf85f 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -31,6 +31,7 @@ import 'voice_call_page.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../../../core/models/chat_message.dart'; +import '../../../core/models/folder.dart'; import '../../../core/models/model.dart'; import '../providers/context_attachments_provider.dart'; import '../../../shared/widgets/loading_states.dart'; @@ -126,6 +127,9 @@ class _ChatPageState extends ConsumerState { // Clear context attachments (web pages, YouTube, knowledge base docs) ref.read(contextAttachmentsProvider.notifier).clear(); + // Clear any pending folder selection + ref.read(pendingFolderIdProvider.notifier).clear(); + // Scroll to top if (_scrollController.hasClients) { _scrollController.jumpTo(0); @@ -956,10 +960,7 @@ class _ChatPageState extends ConsumerState { required Widget child, bool isCircular = false, }) { - return FloatingAppBarPill( - isCircular: isCircular, - child: child, - ); + return FloatingAppBarPill(isCircular: isCircular, child: child); } Widget _buildMessagesList(ThemeData theme) { @@ -1413,6 +1414,16 @@ class _ChatPageState extends ConsumerState { final greetingText = resolvedGreetingName != null ? l10n.onboardStartTitle(resolvedGreetingName) : null; + + // Check if there's a pending folder for the new chat + final pendingFolderId = ref.watch(pendingFolderIdProvider); + final folders = ref + .watch(foldersProvider) + .maybeWhen(data: (list) => list, orElse: () => []); + final pendingFolder = pendingFolderId != null + ? folders.where((f) => f.id == pendingFolderId).firstOrNull + : null; + // Add top padding for floating app bar, bottom padding for floating input. final topPadding = MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; @@ -1439,22 +1450,58 @@ class _ChatPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ - SizedBox( - height: greetingHeight, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - opacity: _greetingReady ? 1 : 0, - child: Align( - alignment: Alignment.center, - child: Text( - _greetingReady ? greetingDisplay : '', + if (pendingFolder != null) ...[ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.newChat, style: greetingStyle, textAlign: TextAlign.center, ), + const SizedBox(height: Spacing.sm), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.folder_fill + : Icons.folder_rounded, + size: 14, + color: context.conduitTheme.textSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + pendingFolder.name, + style: AppTypography.small.copyWith( + color: context.conduitTheme.textSecondary, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ] else ...[ + SizedBox( + height: greetingHeight, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + opacity: _greetingReady ? 1 : 0, + child: Align( + alignment: Alignment.center, + child: Text( + _greetingReady ? greetingDisplay : '', + style: greetingStyle, + textAlign: TextAlign.center, + ), + ), ), ), - ), + ], ], ), ), @@ -1909,8 +1956,9 @@ class _ChatPageState extends ConsumerState { Platform.isIOS ? CupertinoIcons.chevron_down : Icons.keyboard_arrow_down, - color: - context.conduitTheme.iconSecondary, + color: context + .conduitTheme + .iconSecondary, size: IconSize.small, ), ], diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index d1ae4bb..f2d2471 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -1596,8 +1596,9 @@ class _ModernChatInputState extends ConsumerState hintStyle: baseChatStyle.copyWith( color: animatedPlaceholder, fontWeight: recordingWeight, - fontStyle: - _isRecording ? FontStyle.italic : FontStyle.normal, + fontStyle: _isRecording + ? FontStyle.italic + : FontStyle.normal, ), filled: false, border: InputBorder.none, diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index a62e452..69e0e32 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -11,6 +11,7 @@ import '../../../core/providers/app_providers.dart'; import '../../auth/providers/unified_auth_providers.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../chat/providers/chat_providers.dart' as chat; +import '../../chat/providers/context_attachments_provider.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/services/navigation_service.dart'; import '../../../shared/widgets/loading_states.dart'; @@ -1114,24 +1115,75 @@ class _ChatsDrawerState extends ConsumerState { const SizedBox(width: Spacing.sm), Flexible( fit: textFit, - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.standard.copyWith( - color: theme.textPrimary, - fontWeight: FontWeight.w400, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.standard.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.sidebarTheme.accent + .withValues(alpha: 0.7), + borderRadius: + BorderRadius.circular(AppBorderRadius.xs), + border: Border.all( + color: context.sidebarTheme.border + .withValues(alpha: 0.35), + width: BorderWidth.micro, + ), + ), + child: Text( + '$count', + style: AppTypography.tiny.copyWith( + color: context.sidebarTheme.foreground + .withValues(alpha: 0.8), + decoration: TextDecoration.none, + ), + ), + ), + ], ), ), const SizedBox(width: Spacing.sm), - Text( - '$count', - style: AppTypography.standard.copyWith( - color: theme.textSecondary, + SizedBox( + width: 22, + height: 22, + child: IconButton( + iconSize: IconSize.xs, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + shape: const CircleBorder(), + ), + icon: Icon( + Platform.isIOS + ? CupertinoIcons.plus_circle + : Icons.add_circle_outline_rounded, + color: theme.iconSecondary, + size: IconSize.listItem, + ), + onPressed: () { + HapticFeedback.selectionClick(); + _startNewChatInFolder(folderId); + }, + tooltip: AppLocalizations.of(context)!.newChat, ), ), - const SizedBox(width: Spacing.xs), + const SizedBox(width: Spacing.sm), Icon( isExpanded ? (Platform.isIOS @@ -1277,6 +1329,28 @@ class _ChatsDrawerState extends ConsumerState { ); } + void _startNewChatInFolder(String folderId) { + // Set the pending folder ID for the new conversation + ref.read(pendingFolderIdProvider.notifier).set(folderId); + + // Clear current conversation and start fresh + ref.read(chat.chatMessagesProvider.notifier).clearMessages(); + ref.read(activeConversationProvider.notifier).clear(); + + // Clear context attachments (web pages, YouTube, knowledge base docs) + ref.read(contextAttachmentsProvider.notifier).clear(); + + // Close drawer using the responsive layout (same pattern as _selectConversation) + if (mounted) { + final mediaQuery = MediaQuery.maybeOf(context); + final isTablet = + mediaQuery != null && mediaQuery.size.shortestSide >= 600; + if (!isTablet) { + ResponsiveDrawerLayout.of(context)?.close(); + } + } + } + Future _renameFolder( BuildContext context, String folderId, @@ -1640,6 +1714,9 @@ class _ChatsDrawerState extends ConsumerState { container.read(activeConversationProvider.notifier).clear(); container.read(chat.chatMessagesProvider.notifier).clearMessages(); + // Clear any pending folder selection when selecting an existing conversation + container.read(pendingFolderIdProvider.notifier).clear(); + // Close the slide drawer for faster perceived performance // (only on mobile; keep tablet drawer unless user toggles it) if (mounted) {