feat(chat): allow editing user messages
Add an Edit action on user message bubbles that opens a dialog prefilled with the current message content. Saving regenerates the conversation from that point: the edited message replaces the original, the assistant response and any subsequent messages are removed, and a new response is streamed. Implements the editMessage flow in ChatNotifier (mirrors the existing retryMessage pattern) and wires the previously-unbuilt onEdit callback through _MessageList → ChatBubble → MessageActionBar for user-role messages only. Useful when fixing dictation typos without losing the rest of the conversation history above.
Léo Reynal committed
May 6, 2026 at 19:40 UTC
168fd8b3c28c6d90e951f9bafc52eb6be7ac7c91
3 files changed
+197
lib/features/chat/providers/chat_providers.dart
+32
@@ -1106,6 +1106,38 @@ class ChatNotifier extends Notifier<ChatState> {
1106
);
1107
}
1108
1109
+ Future<void> editMessage(String messageId, String newContent) async {
1110
+ final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1111
+ if (messageIndex == -1) return;
1112
+
1113
+ final message = state.messages[messageIndex];
1114
+ if (message.role != MessageRole.user) return;
1115
+ if (newContent.trim().isEmpty) return;
1116
+ if (newContent == message.content) return;
1117
+
1118
+ final attachmentPaths = message.attachmentPaths;
1119
+
1120
+ final messagesToRemove = state.messages.sublist(messageIndex);
1121
+ final db = ref.read(databaseProvider);
1122
+ for (final msg in messagesToRemove) {
1123
+ final query = db.messageBox
1124
+ .query(MessageEntity_.id.equals(msg.id))
1125
+ .build();
1126
+ db.messageBox.removeMany(query.findIds());
1127
+ query.close();
1128
+ }
1129
+
1130
+ state = state.copyWith(
1131
+ messages: state.messages.sublist(0, messageIndex),
1132
+ clearStreaming: true,
1133
+ );
1134
+
1135
+ await sendMessage(
1136
+ newContent,
1137
+ attachments: attachmentPaths?.map((p) => File(p)).toList(),
1138
+ );
1139
+ }
1140
+
1141
Future<void> _saveMessage(Message message) async {
1142
final db = ref.read(databaseProvider);
1143
final query = db.messageBox
lib/features/chat/views/chat_screen.dart
+17
@@ -14,6 +14,7 @@ import '../data/models/message.dart';
14
import '../providers/chat_providers.dart';
15
import 'components/chat_bubble.dart';
16
import 'components/chat_input_bar.dart';
17
+import 'components/edit_message_dialog.dart';
18
import '../providers/chat_mcp_providers.dart';
19
import 'components/chat_settings_sheet.dart';
20
import 'components/notification_permission_banner.dart';
@@ -266,6 +267,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
267
onDelete: (messageId) {
268
ref.read(chatProvider.notifier).deleteMessage(messageId);
269
},
270
+ onEdit: (messageId, currentContent) async {
271
+ final newContent = await EditMessageDialog.show(
272
+ context,
273
+ initialContent: currentContent,
274
+ );
275
+ if (newContent != null && newContent != currentContent) {
276
+ await ref
277
+ .read(chatProvider.notifier)
278
+ .editMessage(messageId, newContent);
279
+ }
280
+ },
281
hasSmartReplies:
282
!chatState.isStreaming &&
283
ref.read(smartRepliesProvider).isNotEmpty,
@@ -1096,6 +1108,7 @@ class _MessageList extends StatelessWidget {
1108
required this.isStreaming,
1109
required this.onRetry,
1110
required this.onDelete,
1111
+ required this.onEdit,
1112
this.hasSmartReplies = false,
1113
});
1114
@@ -1105,6 +1118,7 @@ class _MessageList extends StatelessWidget {
1118
final bool isStreaming;
1119
final void Function(String) onRetry;
1120
final void Function(String) onDelete;
1121
+ final void Function(String messageId, String currentContent) onEdit;
1122
final bool hasSmartReplies;
1123
1124
@override
@@ -1147,6 +1161,9 @@ class _MessageList extends StatelessWidget {
1161
isLast && isStreaming && message.id == streamingMessage?.id,
1162
onRetry: () => onRetry(message.id),
1163
onDelete: () => onDelete(message.id),
1164
+ onEdit: message.role == MessageRole.user
1165
+ ? () => onEdit(message.id, message.content)
1166
+ : null,
1167
);
1168
},
1169
);
lib/features/chat/views/components/edit_message_dialog.dart
new
+148
@@ -0,0 +1,148 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:shadcn_ui/shadcn_ui.dart';
3
+
4
+class EditMessageDialog extends StatefulWidget {
5
+ const EditMessageDialog({super.key, required this.initialContent});
6
+
7
+ final String initialContent;
8
+
9
+ static Future<String?> show(
10
+ BuildContext context, {
11
+ required String initialContent,
12
+ }) {
13
+ return showModalBottomSheet<String>(
14
+ context: context,
15
+ isScrollControlled: true,
16
+ backgroundColor: Colors.transparent,
17
+ builder: (context) => EditMessageDialog(initialContent: initialContent),
18
+ );
19
+ }
20
+
21
+ @override
22
+ State<EditMessageDialog> createState() => _EditMessageDialogState();
23
+}
24
+
25
+class _EditMessageDialogState extends State<EditMessageDialog> {
26
+ late final TextEditingController _controller;
27
+ late final FocusNode _focusNode;
28
+
29
+ @override
30
+ void initState() {
31
+ super.initState();
32
+ _controller = TextEditingController(text: widget.initialContent);
33
+ _focusNode = FocusNode();
34
+ WidgetsBinding.instance.addPostFrameCallback((_) {
35
+ _focusNode.requestFocus();
36
+ _controller.selection = TextSelection(
37
+ baseOffset: 0,
38
+ extentOffset: _controller.text.length,
39
+ );
40
+ });
41
+ }
42
+
43
+ @override
44
+ void dispose() {
45
+ _controller.dispose();
46
+ _focusNode.dispose();
47
+ super.dispose();
48
+ }
49
+
50
+ void _save() {
51
+ final text = _controller.text.trim();
52
+ if (text.isEmpty) return;
53
+ Navigator.of(context).pop(text);
54
+ }
55
+
56
+ @override
57
+ Widget build(BuildContext context) {
58
+ final isDark = Theme.of(context).brightness == Brightness.dark;
59
+ final hintColor = isDark
60
+ ? const Color(0xFF888888)
61
+ : const Color(0xFF666666);
62
+
63
+ return Material(
64
+ type: MaterialType.transparency,
65
+ child: Padding(
66
+ padding: EdgeInsets.only(
67
+ bottom: MediaQuery.of(context).viewInsets.bottom,
68
+ ),
69
+ child: Container(
70
+ padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
71
+ decoration: BoxDecoration(
72
+ color: isDark ? const Color(0xFF121212) : Colors.white,
73
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
74
+ boxShadow: [
75
+ BoxShadow(
76
+ color: Colors.black.withValues(alpha: 0.2),
77
+ blurRadius: 10,
78
+ offset: const Offset(0, -2),
79
+ ),
80
+ ],
81
+ ),
82
+ child: SafeArea(
83
+ top: false,
84
+ child: Column(
85
+ mainAxisSize: MainAxisSize.min,
86
+ crossAxisAlignment: CrossAxisAlignment.start,
87
+ children: [
88
+ Center(
89
+ child: Container(
90
+ width: 32,
91
+ height: 4,
92
+ decoration: BoxDecoration(
93
+ color: isDark
94
+ ? const Color(0xFF2A2A2A)
95
+ : const Color(0xFFE5E5E5),
96
+ borderRadius: BorderRadius.circular(2),
97
+ ),
98
+ ),
99
+ ),
100
+ const SizedBox(height: 16),
101
+ Text(
102
+ 'Edit message',
103
+ style: TextStyle(
104
+ fontSize: 20,
105
+ fontWeight: FontWeight.bold,
106
+ color: isDark ? Colors.white : Colors.black,
107
+ ),
108
+ ),
109
+ const SizedBox(height: 16),
110
+ TextField(
111
+ controller: _controller,
112
+ focusNode: _focusNode,
113
+ maxLines: 8,
114
+ minLines: 3,
115
+ textInputAction: TextInputAction.newline,
116
+ decoration: const InputDecoration(
117
+ border: OutlineInputBorder(),
118
+ contentPadding: EdgeInsets.all(12),
119
+ ),
120
+ ),
121
+ const SizedBox(height: 8),
122
+ Text(
123
+ 'Saving will remove the assistant response below and regenerate.',
124
+ style: TextStyle(fontSize: 12, color: hintColor),
125
+ ),
126
+ const SizedBox(height: 16),
127
+ Row(
128
+ mainAxisAlignment: MainAxisAlignment.end,
129
+ children: [
130
+ ShadButton.outline(
131
+ onPressed: () => Navigator.of(context).pop(),
132
+ child: const Text('Cancel'),
133
+ ),
134
+ const SizedBox(width: 8),
135
+ ShadButton(
136
+ onPressed: _save,
137
+ child: const Text('Save & regenerate'),
138
+ ),
139
+ ],
140
+ ),
141
+ ],
142
+ ),
143
+ ),
144
+ ),
145
+ ),
146
+ );
147
+ }
148
+}