@tduncan / Cortana / commits / 168fd8b

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 +}