From a850a567a11bcc164183c670371a946926b635ba Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 7 Sep 2025 22:37:52 +0530 Subject: [PATCH] feat: inline user message editing --- lib/core/services/api_service.dart | 19 +- lib/core/services/socket_service.dart | 14 ++ .../chat/providers/chat_providers.dart | 15 +- lib/features/chat/views/chat_page.dart | 86 +------ .../chat/widgets/user_message_bubble.dart | 209 +++++++++++++++--- 5 files changed, 224 insertions(+), 119 deletions(-) diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index f06f76a..b4b8c19 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2797,14 +2797,14 @@ class ApiService { debugPrint('DEBUG: Message count: ${processedMessages.length}'); // Debug the data being sent - debugPrint('DEBUG: SSE request data keys: ${data.keys.toList()}'); + debugPrint('DEBUG: SSE request data keys (pre-bg): ${data.keys.toList()}'); debugPrint( - 'DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}', + 'DEBUG: Has background_tasks (pre-bg): ${data.containsKey('background_tasks')}', ); - debugPrint('DEBUG: Has session_id: ${data.containsKey('session_id')}'); - debugPrint('DEBUG: background_tasks value: ${data['background_tasks']}'); - debugPrint('DEBUG: session_id value: ${data['session_id']}'); - debugPrint('DEBUG: id value: ${data['id']}'); + debugPrint('DEBUG: Has session_id (pre-bg): ${data.containsKey('session_id')}'); + debugPrint('DEBUG: background_tasks value (pre-bg): ${data['background_tasks']}'); + debugPrint('DEBUG: session_id value (pre-bg): ${data['session_id']}'); + debugPrint('DEBUG: id value (pre-bg): ${data['id']}'); // Decide whether to use background task flow. // Only enable background task mode when we actually need socket/dynamic-channel @@ -2831,6 +2831,13 @@ class ApiService { data['background_tasks'] = backgroundTasks; } + // Extra diagnostics to confirm dynamic-channel payload + debugPrint('DEBUG: Background flow payload keys: ${data.keys.toList()}'); + debugPrint('DEBUG: Using session_id: $sessionId'); + debugPrint('DEBUG: Using message id: $messageId'); + debugPrint('DEBUG: Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}'); + debugPrint('DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}'); + debugPrint('DEBUG: Initiating background tools flow (task-based)'); debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)'); diff --git a/lib/core/services/socket_service.dart b/lib/core/services/socket_service.dart index ef4bc7e..24e55b3 100644 --- a/lib/core/services/socket_service.dart +++ b/lib/core/services/socket_service.dart @@ -143,4 +143,18 @@ class SocketService { } catch (_) {} _socket = null; } + + // Best-effort: ensure there is an active connection and wait briefly. + // Returns true if connected by the end of the timeout. + Future ensureConnected({Duration timeout = const Duration(seconds: 2)}) async { + if (isConnected) return true; + try { + await connect(); + } catch (_) {} + final start = DateTime.now(); + while (!isConnected && DateTime.now().difference(start) < timeout) { + await Future.delayed(const Duration(milliseconds: 50)); + } + return isConnected; + } } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index e60d6ad..8cf4f98 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -886,10 +886,21 @@ Future regenerateMessage( // Socket binding for background flows final socketService = ref.read(socketServiceProvider); - final socketSessionId = socketService?.sessionId; - final bool wantSessionBinding = + String? socketSessionId = socketService?.sessionId; + bool wantSessionBinding = (socketService?.isConnected == true) && (socketSessionId != null && socketSessionId.isNotEmpty); + // When regenerating with tools, make a best-effort to ensure a live socket. + if (!wantSessionBinding && socketService != null) { + try { + final ok = await socketService.ensureConnected(); + if (ok) { + socketSessionId = socketService.sessionId; + wantSessionBinding = + socketSessionId != null && socketSessionId.isNotEmpty; + } + } catch (_) {} + } // Resolve tool servers from user settings (if any) List>? toolServers; diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 3d74396..775d9c4 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -746,7 +746,6 @@ class _ChatPageState extends ConsumerState { isStreaming: isStreaming, modelName: displayModelName, onCopy: () => _copyMessage(message.content), - onEdit: () => _editMessage(message), onRegenerate: () => _regenerateMessage(message), onLike: () => _likeMessage(message), onDislike: () => _dislikeMessage(message), @@ -834,90 +833,7 @@ class _ChatPageState extends ConsumerState { } } - void _editMessage(dynamic message) async { - if (message.role != 'user') { - return; - } - - final controller = TextEditingController(text: message.content); - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: context.conduitTheme.surfaceBackground, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppBorderRadius.dialog), - ), - title: Text( - AppLocalizations.of(context)!.editMessage, - style: TextStyle(color: context.conduitTheme.textPrimary), - ), - content: TextField( - controller: controller, - style: TextStyle(color: context.conduitTheme.textPrimary), - maxLines: null, - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.messageHintText, - hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder), - border: OutlineInputBorder( - borderSide: BorderSide(color: context.conduitTheme.inputBorder), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: context.conduitTheme.inputBorder), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: context.conduitTheme.buttonPrimary), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Cancel', - style: TextStyle(color: context.conduitTheme.textSecondary), - ), - ), - TextButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), - style: TextButton.styleFrom( - foregroundColor: context.conduitTheme.buttonPrimary, - ), - child: Text(AppLocalizations.of(context)!.save), - ), - ], - ), - ); - - if (result != null && result.isNotEmpty && result != message.content) { - try { - // Find the message index and remove all messages after it - final messages = ref.read(chatMessagesProvider); - final messageIndex = messages.indexOf(message); - - if (messageIndex >= 0) { - // Remove messages from this point onwards - final messagesToKeep = messages.take(messageIndex).toList(); - ref.read(chatMessagesProvider.notifier).setMessages(messagesToKeep); - - // Send the edited message - final selectedModel = ref.read(selectedModelProvider); - if (selectedModel != null) { - final activeConv = ref.read(activeConversationProvider); - await ref.read(taskQueueProvider.notifier).enqueueSendText( - conversationId: activeConv?.id, - text: result, - ); - - if (mounted) {} - } - } - } catch (e) { - if (mounted) {} - } - } - - controller.dispose(); - } + // Inline editing handled by UserMessageBubble. Dialog flow removed. void _likeMessage(dynamic message) { // TODO: Implement message liking diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 3f45a47..6f367b7 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -9,6 +9,10 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/shared/widgets/chat_action_button.dart'; +import '../../../core/providers/app_providers.dart'; +import '../providers/chat_providers.dart'; +import '../../../shared/services/tasks/task_queue.dart'; +import '../../tools/providers/tools_providers.dart'; class UserMessageBubble extends ConsumerStatefulWidget { final dynamic message; @@ -45,6 +49,10 @@ class _UserMessageBubbleState extends ConsumerState late AnimationController _slideController; // press state handled by shared ChatActionButton + bool _isEditing = false; + late final TextEditingController _editController; + final FocusNode _editFocusNode = FocusNode(); + @override void initState() { super.initState(); @@ -56,6 +64,7 @@ class _UserMessageBubbleState extends ConsumerState duration: AnimationDuration.messageSlide, vsync: this, ); + _editController = TextEditingController(text: widget.message?.content ?? ''); } Widget _buildUserAttachmentImages() { @@ -391,6 +400,8 @@ class _UserMessageBubbleState extends ConsumerState void dispose() { _fadeController.dispose(); _slideController.dispose(); + _editController.dispose(); + _editFocusNode.dispose(); super.dispose(); } @@ -423,6 +434,9 @@ class _UserMessageBubbleState extends ConsumerState (widget.message.files as List).any( (f) => f is Map && f['type'] == 'image' && f['url'] != null, ); + // Prefer input/textPrimary colors during inline editing to avoid low contrast + final inlineEditTextColor = context.conduitTheme.textPrimary; + final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(alpha: 0.92); return GestureDetector( onLongPress: () => _toggleActions(), @@ -483,20 +497,80 @@ class _UserMessageBubbleState extends ConsumerState ), ], ), - child: Text( - widget.message.content, - style: AppTypography.chatMessageStyle.copyWith( - color: context.conduitTheme.chatBubbleUserText, - ), - softWrap: true, - textAlign: TextAlign.left, - textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - leadingDistribution: - TextLeadingDistribution.even, - ), - ), + child: _isEditing + ? Focus( + focusNode: _editFocusNode, + autofocus: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: inlineEditFill, + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6), + width: BorderWidth.thin, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: Spacing.xxs, + ), + child: Platform.isIOS + ? CupertinoTextField( + controller: _editController, + maxLines: null, + padding: EdgeInsets.zero, + style: AppTypography + .chatMessageStyle + .copyWith( + color: inlineEditTextColor, + ), + decoration: const BoxDecoration(), + cursorColor: context + .conduitTheme.buttonPrimary, + onSubmitted: (_) => + _saveInlineEdit(), + ) + : TextField( + controller: _editController, + maxLines: null, + style: AppTypography + .chatMessageStyle + .copyWith( + color: inlineEditTextColor, + ), + decoration: + const InputDecoration( + isCollapsed: true, + border: InputBorder.none, + contentPadding: + EdgeInsets.zero, + ), + cursorColor: context + .conduitTheme.buttonPrimary, + onSubmitted: (_) => + _saveInlineEdit(), + ), + ), + ), + ) + : Text( + widget.message.content, + style: AppTypography.chatMessageStyle + .copyWith( + color: context + .conduitTheme.chatBubbleUserText, + ), + softWrap: true, + textAlign: TextAlign.left, + textHeightBehavior: + const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + leadingDistribution: + TextLeadingDistribution.even, + ), + ), ), ), ), @@ -542,19 +616,102 @@ class _UserMessageBubbleState extends ConsumerState spacing: Spacing.sm, runSpacing: Spacing.sm, children: [ - _buildActionButton( - icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, - label: AppLocalizations.of(context)!.edit, - onTap: widget.onEdit, - ), - _buildActionButton( - icon: Platform.isIOS - ? CupertinoIcons.doc_on_clipboard - : Icons.content_copy, - label: AppLocalizations.of(context)!.copy, - onTap: widget.onCopy, - ), + if (_isEditing) ...[ + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.check_mark : Icons.check, + label: AppLocalizations.of(context)!.save, + onTap: _saveInlineEdit, + ), + _buildActionButton( + icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + label: AppLocalizations.of(context)!.cancel, + onTap: _cancelInlineEdit, + ), + ] else ...[ + _buildActionButton( + icon: + Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, + label: AppLocalizations.of(context)!.edit, + onTap: widget.onEdit ?? _startInlineEdit, + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.doc_on_clipboard + : Icons.content_copy, + label: AppLocalizations.of(context)!.copy, + onTap: widget.onCopy, + ), + ], ], ); } + + void _startInlineEdit() { + if (_isEditing) return; + setState(() { + _isEditing = true; + _showActions = true; // ensure actions visible for Save/Cancel + _editController.text = widget.message.content ?? ''; + }); + // Request focus after frame to show keyboard + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _editFocusNode.requestFocus(); + } + }); + } + + void _cancelInlineEdit() { + if (!_isEditing) return; + setState(() { + _isEditing = false; + // keep actions panel open; user can close with long-press + _editController.text = widget.message.content ?? ''; + }); + _editFocusNode.unfocus(); + } + + Future _saveInlineEdit() async { + final newText = _editController.text.trim(); + final oldText = (widget.message.content ?? '').toString(); + if (newText.isEmpty || newText == oldText) { + _cancelInlineEdit(); + return; + } + + try { + // Remove messages after this one + final messages = ref.read(chatMessagesProvider); + final idx = messages.indexOf(widget.message); + if (idx >= 0) { + final keep = messages.take(idx).toList(growable: false); + ref.read(chatMessagesProvider.notifier).setMessages(keep); + + // Enqueue edited text as a new message + final activeConv = ref.read(activeConversationProvider); + final List? attachments = (widget.message.attachmentIds != null && + (widget.message.attachmentIds as List).isNotEmpty) + ? List.from(widget.message.attachmentIds as List) + : null; + final toolIds = ref.read(selectedToolIdsProvider); + await ref + .read(taskQueueProvider.notifier) + .enqueueSendText( + conversationId: activeConv?.id, + text: newText, + attachments: attachments, + toolIds: toolIds.isNotEmpty ? toolIds : null, + ); + } + } catch (_) { + // Swallow errors; upstream error handling will surface if needed + } finally { + if (mounted) { + setState(() { + _isEditing = false; + }); + _editFocusNode.unfocus(); + } + } + } }