feat: inline user message editing

This commit is contained in:
cogwheel0
2025-09-07 22:37:52 +05:30
parent 679eac4dd6
commit a850a567a1
5 changed files with 224 additions and 119 deletions

View File

@@ -2797,14 +2797,14 @@ class ApiService {
debugPrint('DEBUG: Message count: ${processedMessages.length}'); debugPrint('DEBUG: Message count: ${processedMessages.length}');
// Debug the data being sent // 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( 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: Has session_id (pre-bg): ${data.containsKey('session_id')}');
debugPrint('DEBUG: background_tasks value: ${data['background_tasks']}'); debugPrint('DEBUG: background_tasks value (pre-bg): ${data['background_tasks']}');
debugPrint('DEBUG: session_id value: ${data['session_id']}'); debugPrint('DEBUG: session_id value (pre-bg): ${data['session_id']}');
debugPrint('DEBUG: id value: ${data['id']}'); debugPrint('DEBUG: id value (pre-bg): ${data['id']}');
// Decide whether to use background task flow. // Decide whether to use background task flow.
// Only enable background task mode when we actually need socket/dynamic-channel // Only enable background task mode when we actually need socket/dynamic-channel
@@ -2831,6 +2831,13 @@ class ApiService {
data['background_tasks'] = backgroundTasks; 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: Initiating background tools flow (task-based)');
debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)'); debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)');

View File

@@ -143,4 +143,18 @@ class SocketService {
} catch (_) {} } catch (_) {}
_socket = null; _socket = null;
} }
// Best-effort: ensure there is an active connection and wait briefly.
// Returns true if connected by the end of the timeout.
Future<bool> 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;
}
} }

View File

@@ -886,10 +886,21 @@ Future<void> regenerateMessage(
// Socket binding for background flows // Socket binding for background flows
final socketService = ref.read(socketServiceProvider); final socketService = ref.read(socketServiceProvider);
final socketSessionId = socketService?.sessionId; String? socketSessionId = socketService?.sessionId;
final bool wantSessionBinding = bool wantSessionBinding =
(socketService?.isConnected == true) && (socketService?.isConnected == true) &&
(socketSessionId != null && socketSessionId.isNotEmpty); (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) // Resolve tool servers from user settings (if any)
List<Map<String, dynamic>>? toolServers; List<Map<String, dynamic>>? toolServers;

View File

@@ -746,7 +746,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
isStreaming: isStreaming, isStreaming: isStreaming,
modelName: displayModelName, modelName: displayModelName,
onCopy: () => _copyMessage(message.content), onCopy: () => _copyMessage(message.content),
onEdit: () => _editMessage(message),
onRegenerate: () => _regenerateMessage(message), onRegenerate: () => _regenerateMessage(message),
onLike: () => _likeMessage(message), onLike: () => _likeMessage(message),
onDislike: () => _dislikeMessage(message), onDislike: () => _dislikeMessage(message),
@@ -834,90 +833,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} }
} }
void _editMessage(dynamic message) async { // Inline editing handled by UserMessageBubble. Dialog flow removed.
if (message.role != 'user') {
return;
}
final controller = TextEditingController(text: message.content);
final result = await showDialog<String>(
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();
}
void _likeMessage(dynamic message) { void _likeMessage(dynamic message) {
// TODO: Implement message liking // TODO: Implement message liking

View File

@@ -9,6 +9,10 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import 'package:conduit/shared/widgets/chat_action_button.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 { class UserMessageBubble extends ConsumerStatefulWidget {
final dynamic message; final dynamic message;
@@ -45,6 +49,10 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
late AnimationController _slideController; late AnimationController _slideController;
// press state handled by shared ChatActionButton // press state handled by shared ChatActionButton
bool _isEditing = false;
late final TextEditingController _editController;
final FocusNode _editFocusNode = FocusNode();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -56,6 +64,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
duration: AnimationDuration.messageSlide, duration: AnimationDuration.messageSlide,
vsync: this, vsync: this,
); );
_editController = TextEditingController(text: widget.message?.content ?? '');
} }
Widget _buildUserAttachmentImages() { Widget _buildUserAttachmentImages() {
@@ -391,6 +400,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
void dispose() { void dispose() {
_fadeController.dispose(); _fadeController.dispose();
_slideController.dispose(); _slideController.dispose();
_editController.dispose();
_editFocusNode.dispose();
super.dispose(); super.dispose();
} }
@@ -423,6 +434,9 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
(widget.message.files as List).any( (widget.message.files as List).any(
(f) => f is Map && f['type'] == 'image' && f['url'] != null, (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( return GestureDetector(
onLongPress: () => _toggleActions(), onLongPress: () => _toggleActions(),
@@ -483,14 +497,74 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
), ),
], ],
), ),
child: Text( 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, widget.message.content,
style: AppTypography.chatMessageStyle.copyWith( style: AppTypography.chatMessageStyle
color: context.conduitTheme.chatBubbleUserText, .copyWith(
color: context
.conduitTheme.chatBubbleUserText,
), ),
softWrap: true, softWrap: true,
textAlign: TextAlign.left, textAlign: TextAlign.left,
textHeightBehavior: const TextHeightBehavior( textHeightBehavior:
const TextHeightBehavior(
applyHeightToFirstAscent: false, applyHeightToFirstAscent: false,
applyHeightToLastDescent: false, applyHeightToLastDescent: false,
leadingDistribution: leadingDistribution:
@@ -542,10 +616,23 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
spacing: Spacing.sm, spacing: Spacing.sm,
runSpacing: Spacing.sm, runSpacing: Spacing.sm,
children: [ children: [
if (_isEditing) ...[
_buildActionButton( _buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined, 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, label: AppLocalizations.of(context)!.edit,
onTap: widget.onEdit, onTap: widget.onEdit ?? _startInlineEdit,
), ),
_buildActionButton( _buildActionButton(
icon: Platform.isIOS icon: Platform.isIOS
@@ -555,6 +642,76 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
onTap: widget.onCopy, 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<void> _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<String>? attachments = (widget.message.attachmentIds != null &&
(widget.message.attachmentIds as List).isNotEmpty)
? List<String>.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();
}
}
}
} }