feat(chat): Add folder support in new chat screen

This commit is contained in:
cogwheel0
2025-12-16 19:59:28 +05:30
parent a0aee08e73
commit d67780dbbe
6 changed files with 206 additions and 38 deletions

View File

@@ -853,6 +853,21 @@ class SelectedModel extends _$SelectedModel {
void clear() => state = null; 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) // Track if the current model selection is manual (user-selected) or automatic (default)
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
class IsManualModelSelection extends _$IsManualModelSelection { class IsManualModelSelection extends _$IsManualModelSelection {

View File

@@ -812,6 +812,7 @@ class ApiService {
required List<ChatMessage> messages, required List<ChatMessage> messages,
String? model, String? model,
String? systemPrompt, String? systemPrompt,
String? folderId,
}) async { }) async {
_traceApi('Creating new conversation on OpenWebUI server'); _traceApi('Creating new conversation on OpenWebUI server');
_traceApi('Title: $title, Messages: ${messages.length}'); _traceApi('Title: $title, Messages: ${messages.length}');
@@ -893,7 +894,7 @@ class ApiService {
'tags': [], 'tags': [],
'timestamp': DateTime.now().millisecondsSinceEpoch, 'timestamp': DateTime.now().millisecondsSinceEpoch,
}, },
'folder_id': null, 'folder_id': folderId,
}; };
_traceApi('Sending chat data with proper parent-child structure'); _traceApi('Sending chat data with proper parent-child structure');

View File

@@ -981,6 +981,9 @@ void startNewChat(dynamic ref) {
// Clear context attachments (web pages, YouTube, knowledge base docs) // Clear context attachments (web pages, YouTube, knowledge base docs)
ref.read(contextAttachmentsProvider.notifier).clear(); ref.read(contextAttachmentsProvider.notifier).clear();
// Clear any pending folder selection
ref.read(pendingFolderIdProvider.notifier).clear();
} }
// Available tools provider // Available tools provider
@@ -1892,6 +1895,9 @@ Future<void> _sendMessageInternal(
); );
if (activeConversation == null) { 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 // Create new conversation with the first message included
final localConversation = Conversation( final localConversation = Conversation(
id: const Uuid().v4(), id: const Uuid().v4(),
@@ -1900,6 +1906,7 @@ Future<void> _sendMessageInternal(
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
systemPrompt: userSystemPrompt, systemPrompt: userSystemPrompt,
messages: [userMessage], // Include the user message messages: [userMessage], // Include the user message
folderId: pendingFolderId,
); );
// Set as active conversation locally // Set as active conversation locally
@@ -1914,13 +1921,19 @@ Future<void> _sendMessageInternal(
messages: [userMessage], // Include the first message in creation messages: [userMessage], // Include the first message in creation
model: selectedModel.id, model: selectedModel.id,
systemPrompt: userSystemPrompt, systemPrompt: userSystemPrompt,
folderId: pendingFolderId,
); );
// Clear the pending folder ID after successful creation
ref.read(pendingFolderIdProvider.notifier).clear();
final updatedConversation = localConversation.copyWith( final updatedConversation = localConversation.copyWith(
id: serverConversation.id, id: serverConversation.id,
systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt, systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt,
messages: serverConversation.messages.isNotEmpty messages: serverConversation.messages.isNotEmpty
? serverConversation.messages ? serverConversation.messages
: [userMessage], : [userMessage],
folderId: serverConversation.folderId ?? pendingFolderId,
); );
ref.read(activeConversationProvider.notifier).set(updatedConversation); ref.read(activeConversationProvider.notifier).set(updatedConversation);
activeConversation = updatedConversation; activeConversation = updatedConversation;
@@ -1945,7 +1958,10 @@ Future<void> _sendMessageInternal(
// handle any disposal gracefully. // handle any disposal gracefully.
final isMounted = ref is Ref ? ref.mounted : true; final isMounted = ref is Ref ? ref.mounted : true;
if (isMounted) { if (isMounted) {
refreshConversationsCache(ref); refreshConversationsCache(
ref,
includeFolders: pendingFolderId != null,
);
} }
} catch (_) { } catch (_) {
// If ref is disposed or invalid, skip // If ref is disposed or invalid, skip
@@ -1954,10 +1970,16 @@ Future<void> _sendMessageInternal(
} catch (e) { } catch (e) {
// Still add the message locally // Still add the message locally
ref.read(chatMessagesProvider.notifier).addMessage(userMessage); ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
// Clear the pending folder ID on failure to prevent stale state
ref.read(pendingFolderIdProvider.notifier).clear();
} }
} else { } else {
// Add message for reviewer mode // Add message for reviewer mode
ref.read(chatMessagesProvider.notifier).addMessage(userMessage); ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
// Clear the pending folder ID even in reviewer mode
ref.read(pendingFolderIdProvider.notifier).clear();
} }
} else { } else {
// Add user message to existing conversation // Add user message to existing conversation
@@ -2490,7 +2512,8 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
isStreaming: false, isStreaming: false,
error: const ChatMessageError( 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 ' 'because the image attachment couldn\'t be processed, the request '
'format is incompatible with the selected model, or the message ' 'format is incompatible with the selected model, or the message '
'contains unsupported content. Please try sending the message ' 'contains unsupported content. Please try sending the message '
@@ -2509,7 +2532,8 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
isStreaming: false, isStreaming: false,
error: const ChatMessageError( 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 ' 'error (500). This is typically a server-side issue. Please try '
'again or contact your administrator.', 'again or contact your administrator.',
), ),
@@ -2527,7 +2551,8 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
isStreaming: false, isStreaming: false,
error: const ChatMessageError( 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 ' 'Please try selecting a different model or check with your '
'administrator.', 'administrator.',
), ),
@@ -2542,7 +2567,8 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
isStreaming: false, isStreaming: false,
error: const ChatMessageError( 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.', 'Please try again or check your connection.',
), ),
); );

View File

@@ -31,6 +31,7 @@ import 'voice_call_page.dart';
import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/services/tasks/task_queue.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../../core/models/chat_message.dart'; import '../../../core/models/chat_message.dart';
import '../../../core/models/folder.dart';
import '../../../core/models/model.dart'; import '../../../core/models/model.dart';
import '../providers/context_attachments_provider.dart'; import '../providers/context_attachments_provider.dart';
import '../../../shared/widgets/loading_states.dart'; import '../../../shared/widgets/loading_states.dart';
@@ -126,6 +127,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Clear context attachments (web pages, YouTube, knowledge base docs) // Clear context attachments (web pages, YouTube, knowledge base docs)
ref.read(contextAttachmentsProvider.notifier).clear(); ref.read(contextAttachmentsProvider.notifier).clear();
// Clear any pending folder selection
ref.read(pendingFolderIdProvider.notifier).clear();
// Scroll to top // Scroll to top
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.jumpTo(0); _scrollController.jumpTo(0);
@@ -956,10 +960,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
required Widget child, required Widget child,
bool isCircular = false, bool isCircular = false,
}) { }) {
return FloatingAppBarPill( return FloatingAppBarPill(isCircular: isCircular, child: child);
isCircular: isCircular,
child: child,
);
} }
Widget _buildMessagesList(ThemeData theme) { Widget _buildMessagesList(ThemeData theme) {
@@ -1413,6 +1414,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final greetingText = resolvedGreetingName != null final greetingText = resolvedGreetingName != null
? l10n.onboardStartTitle(resolvedGreetingName) ? l10n.onboardStartTitle(resolvedGreetingName)
: null; : 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: () => <Folder>[]);
final pendingFolder = pendingFolderId != null
? folders.where((f) => f.id == pendingFolderId).firstOrNull
: null;
// Add top padding for floating app bar, bottom padding for floating input. // Add top padding for floating app bar, bottom padding for floating input.
final topPadding = final topPadding =
MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md; MediaQuery.of(context).padding.top + kToolbarHeight + Spacing.md;
@@ -1439,22 +1450,58 @@ class _ChatPageState extends ConsumerState<ChatPage> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: [ children: [
SizedBox( if (pendingFolder != null) ...[
height: greetingHeight, Column(
child: AnimatedOpacity( mainAxisSize: MainAxisSize.min,
duration: const Duration(milliseconds: 260), children: [
curve: Curves.easeOutCubic, Text(
opacity: _greetingReady ? 1 : 0, l10n.newChat,
child: Align(
alignment: Alignment.center,
child: Text(
_greetingReady ? greetingDisplay : '',
style: greetingStyle, style: greetingStyle,
textAlign: TextAlign.center, 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<ChatPage> {
Platform.isIOS Platform.isIOS
? CupertinoIcons.chevron_down ? CupertinoIcons.chevron_down
: Icons.keyboard_arrow_down, : Icons.keyboard_arrow_down,
color: color: context
context.conduitTheme.iconSecondary, .conduitTheme
.iconSecondary,
size: IconSize.small, size: IconSize.small,
), ),
], ],

View File

@@ -1596,8 +1596,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
hintStyle: baseChatStyle.copyWith( hintStyle: baseChatStyle.copyWith(
color: animatedPlaceholder, color: animatedPlaceholder,
fontWeight: recordingWeight, fontWeight: recordingWeight,
fontStyle: fontStyle: _isRecording
_isRecording ? FontStyle.italic : FontStyle.normal, ? FontStyle.italic
: FontStyle.normal,
), ),
filled: false, filled: false,
border: InputBorder.none, border: InputBorder.none,

View File

@@ -11,6 +11,7 @@ import '../../../core/providers/app_providers.dart';
import '../../auth/providers/unified_auth_providers.dart'; import '../../auth/providers/unified_auth_providers.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../chat/providers/chat_providers.dart' as chat; import '../../chat/providers/chat_providers.dart' as chat;
import '../../chat/providers/context_attachments_provider.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/services/navigation_service.dart'; import '../../../core/services/navigation_service.dart';
import '../../../shared/widgets/loading_states.dart'; import '../../../shared/widgets/loading_states.dart';
@@ -1114,24 +1115,75 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.sm),
Flexible( Flexible(
fit: textFit, fit: textFit,
child: Text( child: Row(
name, mainAxisSize: MainAxisSize.min,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Flexible(
style: AppTypography.standard.copyWith( child: Text(
color: theme.textPrimary, name,
fontWeight: FontWeight.w400, 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), const SizedBox(width: Spacing.sm),
Text( SizedBox(
'$count', width: 22,
style: AppTypography.standard.copyWith( height: 22,
color: theme.textSecondary, 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( Icon(
isExpanded isExpanded
? (Platform.isIOS ? (Platform.isIOS
@@ -1277,6 +1329,28 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
); );
} }
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<void> _renameFolder( Future<void> _renameFolder(
BuildContext context, BuildContext context,
String folderId, String folderId,
@@ -1640,6 +1714,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
container.read(activeConversationProvider.notifier).clear(); container.read(activeConversationProvider.notifier).clear();
container.read(chat.chatMessagesProvider.notifier).clearMessages(); 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 // Close the slide drawer for faster perceived performance
// (only on mobile; keep tablet drawer unless user toggles it) // (only on mobile; keep tablet drawer unless user toggles it)
if (mounted) { if (mounted) {