agent_ui: Use main message editor for editing queued messages (#50490)

Danilo Leal created

This PR changes the behavior of editing queued messages. Previously, you
could type directly in the editor that was used to displayed a queued
message. However, that felt a bit anxiety-inducing because there was a
chance the message would get sent before you could've wrapped up your
edits. And even if we blocked the message to get sent while focused on
the editor, it could still be a bit unpredictable. So, with this PR, if
you type on a queued message, the content of that message goes
immediately back to the main message editor. Effectively, you're taking
that message off the queue so you can edit it, and then if you hit enter
(while there's a generation ongoing), that content goes back into the
queue.

I believe this allows for a calmer editing experience when wanting to
touch up or increment a queued message.

Here's a video of the flow:


https://github.com/user-attachments/assets/ec60ccad-2b04-4b7c-a8f5-95baa443d9f2

In the video, I'm showcasing a few things:
- in case the main message editor already had content, the content from
the edited queued message is appended to it. That avoids the user
surprisingly losing information.
- the fact that mention creases still work well and are not converted
back into plain text

Release Notes:

- Agent: Made editing queued messages better by moving the contnet back
to the main message editor, avoiding the chance of it getting sent
mid-editing.

Change summary

assets/icons/queue_message.svg                     |   7 
crates/agent_ui/src/connection_view.rs             | 100 ++++
crates/agent_ui/src/connection_view/thread_view.rs | 156 ++++---
crates/agent_ui/src/entry_view_state.rs            |   2 
crates/agent_ui/src/message_editor.rs              | 327 +++++++++++++++
crates/icons/src/icons.rs                          |   1 
6 files changed, 514 insertions(+), 79 deletions(-)

Detailed changes

assets/icons/queue_message.svg 🔗

@@ -0,0 +1,7 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.3404 3.53018H2.73401" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.3404 7.62592H2.73401" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.24467 11.7217H2.73401" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10.3404 9.96637L8.58511 11.7217L10.3404 13.477" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.266 3.53018V10.5515C13.266 10.8619 13.1427 11.1595 12.9232 11.379C12.7038 11.5984 12.4061 11.7217 12.0958 11.7217H8.58511" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/agent_ui/src/connection_view.rs 🔗

@@ -1165,6 +1165,20 @@ impl ConnectionView {
         }
     }
 
+    fn move_queued_message_to_main_editor(
+        &mut self,
+        index: usize,
+        inserted_text: Option<&str>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(active) = self.active_thread() {
+            active.update(cx, |active, cx| {
+                active.move_queued_message_to_main_editor(index, inserted_text, window, cx);
+            });
+        }
+    }
+
     fn handle_thread_event(
         &mut self,
         thread: &Entity<AcpThread>,
@@ -2162,6 +2176,7 @@ impl ConnectionView {
             for (index, editor) in editors.into_iter().enumerate() {
                 if let Some(content) = queued_messages.get(index) {
                     editor.update(cx, |editor, cx| {
+                        editor.set_read_only(true, cx);
                         editor.set_message(content.clone(), window, cx);
                     });
                 }
@@ -2190,6 +2205,7 @@ impl ConnectionView {
                     window,
                     cx,
                 );
+                editor.set_read_only(true, cx);
                 editor.set_message(content, window, cx);
                 editor
             });
@@ -2198,6 +2214,8 @@ impl ConnectionView {
                 &editor,
                 window,
                 move |this, _editor, event, window, cx| match event {
+                    MessageEditorEvent::InputAttempted(text) => this
+                        .move_queued_message_to_main_editor(index, Some(text.as_ref()), window, cx),
                     MessageEditorEvent::LostFocus => {
                         this.save_queued_message_at_index(index, cx);
                     }
@@ -6084,4 +6102,86 @@ pub(crate) mod tests {
             assert_eq!(tool_call_id, acp::ToolCallId::new("tc-b"));
         });
     }
+
+    #[gpui::test]
+    async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (connection_view, cx) =
+            setup_thread_view(StubAgentServer::default_response(), cx).await;
+
+        // Add a plain-text message to the queue directly.
+        active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| {
+            thread.add_to_queue(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "queued message".to_string(),
+                ))],
+                vec![],
+                cx,
+            );
+            // Main editor must be empty for this path — it is by default, but
+            // assert to make the precondition explicit.
+            assert!(thread.message_editor.read(cx).is_empty(cx));
+            thread.move_queued_message_to_main_editor(0, None, window, cx);
+        });
+
+        cx.run_until_parked();
+
+        // Queue should now be empty.
+        let queue_len = active_thread(&connection_view, cx)
+            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
+        assert_eq!(queue_len, 0, "Queue should be empty after move");
+
+        // Main editor should contain the queued message text.
+        let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(
+            text, "queued message",
+            "Main editor should contain the moved queued message"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (connection_view, cx) =
+            setup_thread_view(StubAgentServer::default_response(), cx).await;
+
+        // Seed the main editor with existing content.
+        message_editor(&connection_view, cx).update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "existing content".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        // Add a plain-text message to the queue.
+        active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| {
+            thread.add_to_queue(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "queued message".to_string(),
+                ))],
+                vec![],
+                cx,
+            );
+            thread.move_queued_message_to_main_editor(0, None, window, cx);
+        });
+
+        cx.run_until_parked();
+
+        // Queue should now be empty.
+        let queue_len = active_thread(&connection_view, cx)
+            .read_with(cx, |thread, _cx| thread.local_queued_messages.len());
+        assert_eq!(queue_len, 0, "Queue should be empty after move");
+
+        // Main editor should contain existing content + separator + queued content.
+        let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(
+            text, "existing content\n\nqueued message",
+            "Main editor should have existing content and queued message separated by two newlines"
+        );
+    }
 }

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -463,6 +463,7 @@ impl ThreadView {
                 self.cancel_editing(&Default::default(), window, cx);
             }
             MessageEditorEvent::LostFocus => {}
+            MessageEditorEvent::InputAttempted(_) => {}
         }
     }
 
@@ -577,6 +578,7 @@ impl ThreadView {
             ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
                 self.cancel_editing(&Default::default(), window, cx);
             }
+            ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted(_)) => {}
             ViewEvent::OpenDiffLocation {
                 path,
                 position,
@@ -1217,6 +1219,44 @@ impl ThreadView {
         self.send_content(contents_task, window, cx);
     }
 
+    pub fn move_queued_message_to_main_editor(
+        &mut self,
+        index: usize,
+        inserted_text: Option<&str>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        let Some(queued_message) = self.remove_from_queue(index, cx) else {
+            return false;
+        };
+        let queued_content = queued_message.content;
+        let message_editor = self.message_editor.clone();
+        let inserted_text = inserted_text.map(ToOwned::to_owned);
+
+        window.focus(&message_editor.focus_handle(cx), cx);
+
+        if message_editor.read(cx).is_empty(cx) {
+            message_editor.update(cx, |editor, cx| {
+                editor.set_message(queued_content, window, cx);
+                if let Some(inserted_text) = inserted_text.as_deref() {
+                    editor.insert_text(inserted_text, window, cx);
+                }
+            });
+            cx.notify();
+            return true;
+        }
+
+        message_editor.update(cx, |editor, cx| {
+            editor.append_message(queued_content, Some("\n\n"), window, cx);
+            if let Some(inserted_text) = inserted_text.as_deref() {
+                editor.insert_text(inserted_text, window, cx);
+            }
+        });
+
+        cx.notify();
+        true
+    }
+
     // editor methods
 
     pub fn expand_message_editor(
@@ -2663,50 +2703,24 @@ impl ThreadView {
                             .child(if editor_focused {
                                 h_flex()
                                     .gap_1()
-                                    .min_w_40()
+                                    .min_w(rems_from_px(150.))
+                                    .justify_end()
                                     .child(
-                                        IconButton::new(("cancel_edit", index), IconName::Close)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Error)
-                                            .tooltip({
-                                                let focus_handle = editor.focus_handle(cx);
-                                                move |_window, cx| {
-                                                    Tooltip::for_action_in(
-                                                        "Cancel Edit",
-                                                        &editor::actions::Cancel,
-                                                        &focus_handle,
-                                                        cx,
-                                                    )
-                                                }
-                                            })
-                                            .on_click({
-                                                let main_editor = self.message_editor.clone();
-                                                cx.listener(move |_, _, window, cx| {
-                                                    window.focus(&main_editor.focus_handle(cx), cx);
-                                                })
-                                            }),
-                                    )
-                                    .child(
-                                        IconButton::new(("save_edit", index), IconName::Check)
+                                        IconButton::new(("edit", index), IconName::Pencil)
                                             .icon_size(IconSize::Small)
-                                            .icon_color(Color::Success)
-                                            .tooltip({
-                                                let focus_handle = editor.focus_handle(cx);
-                                                move |_window, cx| {
-                                                    Tooltip::for_action_in(
-                                                        "Save Edit",
-                                                        &Chat,
-                                                        &focus_handle,
-                                                        cx,
-                                                    )
-                                                }
+                                            .tooltip(|_window, cx| {
+                                                Tooltip::with_meta(
+                                                    "Edit Queued Message",
+                                                    None,
+                                                    "Type anything to edit",
+                                                    cx,
+                                                )
                                             })
-                                            .on_click({
-                                                let main_editor = self.message_editor.clone();
-                                                cx.listener(move |_, _, window, cx| {
-                                                    window.focus(&main_editor.focus_handle(cx), cx);
-                                                })
-                                            }),
+                                            .on_click(cx.listener(move |this, _, window, cx| {
+                                                this.move_queued_message_to_main_editor(
+                                                    index, None, window, cx,
+                                                );
+                                            })),
                                     )
                                     .child(
                                         Button::new(("send_now_focused", index), "Send Now")
@@ -2728,62 +2742,64 @@ impl ThreadView {
                                     )
                             } else {
                                 h_flex()
-                                    .gap_1()
                                     .when(!is_next, |this| this.visible_on_hover("queue_entry"))
+                                    .gap_1()
+                                    .min_w(rems_from_px(150.))
+                                    .justify_end()
                                     .child(
-                                        IconButton::new(("edit", index), IconName::Pencil)
+                                        IconButton::new(("delete", index), IconName::Trash)
                                             .icon_size(IconSize::Small)
                                             .tooltip({
                                                 let focus_handle = focus_handle.clone();
                                                 move |_window, cx| {
                                                     if is_next {
                                                         Tooltip::for_action_in(
-                                                            "Edit",
-                                                            &EditFirstQueuedMessage,
+                                                            "Remove Message from Queue",
+                                                            &RemoveFirstQueuedMessage,
                                                             &focus_handle,
                                                             cx,
                                                         )
                                                     } else {
-                                                        Tooltip::simple("Edit", cx)
+                                                        Tooltip::simple(
+                                                            "Remove Message from Queue",
+                                                            cx,
+                                                        )
                                                     }
                                                 }
                                             })
-                                            .on_click({
-                                                let editor = editor.clone();
-                                                cx.listener(move |_, _, window, cx| {
-                                                    window.focus(&editor.focus_handle(cx), cx);
-                                                })
-                                            }),
+                                            .on_click(cx.listener(move |this, _, _, cx| {
+                                                this.remove_from_queue(index, cx);
+                                                cx.notify();
+                                            })),
                                     )
                                     .child(
-                                        IconButton::new(("delete", index), IconName::Trash)
+                                        IconButton::new(("edit", index), IconName::Pencil)
                                             .icon_size(IconSize::Small)
                                             .tooltip({
                                                 let focus_handle = focus_handle.clone();
                                                 move |_window, cx| {
                                                     if is_next {
                                                         Tooltip::for_action_in(
-                                                            "Remove Message from Queue",
-                                                            &RemoveFirstQueuedMessage,
+                                                            "Edit",
+                                                            &EditFirstQueuedMessage,
                                                             &focus_handle,
                                                             cx,
                                                         )
                                                     } else {
-                                                        Tooltip::simple(
-                                                            "Remove Message from Queue",
-                                                            cx,
-                                                        )
+                                                        Tooltip::simple("Edit", cx)
                                                     }
                                                 }
                                             })
-                                            .on_click(cx.listener(move |this, _, _, cx| {
-                                                this.remove_from_queue(index, cx);
-                                                cx.notify();
+                                            .on_click(cx.listener(move |this, _, window, cx| {
+                                                this.move_queued_message_to_main_editor(
+                                                    index, None, window, cx,
+                                                );
                                             })),
                                     )
                                     .child(
                                         Button::new(("send_now", index), "Send Now")
                                             .label_size(LabelSize::Small)
+                                            .when(is_next, |this| this.style(ButtonStyle::Outlined))
                                             .when(is_next && message_editor.is_empty(cx), |this| {
                                                 let action: Box<dyn gpui::Action> =
                                                     if can_fast_track {
@@ -2792,7 +2808,7 @@ impl ThreadView {
                                                         Box::new(SendNextQueuedMessage)
                                                     };
 
-                                                this.style(ButtonStyle::Outlined).key_binding(
+                                                this.key_binding(
                                                     KeyBinding::for_action_in(
                                                         action.as_ref(),
                                                         &focus_handle.clone(),
@@ -2801,9 +2817,6 @@ impl ThreadView {
                                                     .map(|kb| kb.size(keybinding_size)),
                                                 )
                                             })
-                                            .when(is_next && !message_editor.is_empty(cx), |this| {
-                                                this.style(ButtonStyle::Outlined)
-                                            })
                                             .on_click(cx.listener(move |this, _, window, cx| {
                                                 this.send_queued_message_at_index(
                                                     index, true, window, cx,
@@ -3281,7 +3294,12 @@ impl ThreadView {
                 .on_click(cx.listener(|this, _event, _, cx| this.cancel_generation(cx)))
                 .into_any_element()
         } else {
-            IconButton::new("send-message", IconName::Send)
+            let send_icon = if is_generating {
+                IconName::QueueMessage
+            } else {
+                IconName::Send
+            };
+            IconButton::new("send-message", send_icon)
                 .style(ButtonStyle::Filled)
                 .map(|this| {
                     if is_editor_empty && !is_generating {
@@ -7577,9 +7595,7 @@ impl Render for ThreadView {
                 cx.notify();
             }))
             .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
-                if let Some(editor) = this.queued_message_editors.first() {
-                    window.focus(&editor.focus_handle(cx), cx);
-                }
+                this.move_queued_message_to_main_editor(0, None, window, cx);
             }))
             .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
                 this.local_queued_messages.clear();

crates/agent_ui/src/entry_view_state.rs 🔗

@@ -114,7 +114,7 @@ impl EntryViewState {
                     cx.subscribe(&message_editor, move |_, editor, event, cx| {
                         cx.emit(EntryViewEvent {
                             entry_index: index,
-                            view_event: ViewEvent::MessageEditorEvent(editor, *event),
+                            view_event: ViewEvent::MessageEditorEvent(editor, event.clone()),
                         })
                     })
                     .detach();

crates/agent_ui/src/message_editor.rs 🔗

@@ -51,13 +51,14 @@ pub struct MessageEditor {
     _parse_slash_command_task: Task<()>,
 }
 
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Debug)]
 pub enum MessageEditorEvent {
     Send,
     SendImmediately,
     Cancel,
     Focus,
     LostFocus,
+    InputAttempted(Arc<str>),
 }
 
 impl EventEmitter<MessageEditorEvent> for MessageEditor {}
@@ -186,6 +187,18 @@ impl MessageEditor {
 
         subscriptions.push(cx.subscribe_in(&editor, window, {
             move |this, editor, event, window, cx| {
+                let input_attempted_text = match event {
+                    EditorEvent::InputHandled { text, .. } => Some(text),
+                    EditorEvent::InputIgnored { text } => Some(text),
+                    _ => None,
+                };
+                if let Some(text) = input_attempted_text
+                    && editor.read(cx).read_only(cx)
+                    && !text.is_empty()
+                {
+                    cx.emit(MessageEditorEvent::InputAttempted(text.clone()));
+                }
+
                 if let EditorEvent::Edited { .. } = event
                     && !editor.read(cx).read_only(cx)
                 {
@@ -1195,13 +1208,45 @@ impl MessageEditor {
         message: Vec<acp::ContentBlock>,
         window: &mut Window,
         cx: &mut Context<Self>,
+    ) {
+        self.clear(window, cx);
+        self.insert_message_blocks(message, false, window, cx);
+    }
+
+    pub fn append_message(
+        &mut self,
+        message: Vec<acp::ContentBlock>,
+        separator: Option<&str>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if message.is_empty() {
+            return;
+        }
+
+        if let Some(separator) = separator
+            && !separator.is_empty()
+            && !self.is_empty(cx)
+        {
+            self.editor.update(cx, |editor, cx| {
+                editor.insert(separator, window, cx);
+            });
+        }
+
+        self.insert_message_blocks(message, true, window, cx);
+    }
+
+    fn insert_message_blocks(
+        &mut self,
+        message: Vec<acp::ContentBlock>,
+        append_to_existing: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
     ) {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
 
-        self.clear(window, cx);
-
         let path_style = workspace.read(cx).project().read(cx).path_style(cx);
         let mut text = String::new();
         let mut mentions = Vec::new();
@@ -1275,13 +1320,31 @@ impl MessageEditor {
             }
         }
 
-        let snapshot = self.editor.update(cx, |editor, cx| {
-            editor.set_text(text, window, cx);
-            editor.buffer().read(cx).snapshot(cx)
-        });
+        if text.is_empty() && mentions.is_empty() {
+            return;
+        }
+
+        let insertion_start = if append_to_existing {
+            self.editor.read(cx).text(cx).len()
+        } else {
+            0
+        };
+
+        let snapshot = if append_to_existing {
+            self.editor.update(cx, |editor, cx| {
+                editor.insert(&text, window, cx);
+                editor.buffer().read(cx).snapshot(cx)
+            })
+        } else {
+            self.editor.update(cx, |editor, cx| {
+                editor.set_text(text, window, cx);
+                editor.buffer().read(cx).snapshot(cx)
+            })
+        };
 
         for (range, mention_uri, mention) in mentions {
-            let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
+            let adjusted_start = insertion_start + range.start;
+            let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
             let Some((crease_id, tx)) = insert_crease_for_mention(
                 anchor.excerpt_id,
                 anchor.text_anchor,
@@ -1306,6 +1369,7 @@ impl MessageEditor {
                 )
             });
         }
+
         cx.notify();
     }
 
@@ -1313,6 +1377,16 @@ impl MessageEditor {
         self.editor.read(cx).text(cx)
     }
 
+    pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
+        if text.is_empty() {
+            return;
+        }
+
+        self.editor.update(cx, |editor, cx| {
+            editor.insert(text, window, cx);
+        });
+    }
+
     pub fn set_placeholder_text(
         &mut self,
         placeholder: &str,
@@ -3461,4 +3535,241 @@ mod tests {
             text
         );
     }
+
+    // Helper that creates a minimal MessageEditor inside a window, returning both
+    // the entity and the underlying VisualTestContext so callers can drive updates.
+    async fn setup_message_editor(
+        cx: &mut TestAppContext,
+    ) -> (Entity<MessageEditor>, &mut VisualTestContext) {
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({"file.txt": ""})).await;
+        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        let history =
+            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+
+        let message_editor = cx.update(|window, cx| {
+            cx.new(|cx| {
+                MessageEditor::new(
+                    workspace.downgrade(),
+                    project.downgrade(),
+                    None,
+                    history.downgrade(),
+                    None,
+                    Default::default(),
+                    Default::default(),
+                    "Test Agent".into(),
+                    "Test",
+                    EditorMode::AutoHeight {
+                        min_lines: 1,
+                        max_lines: None,
+                    },
+                    window,
+                    cx,
+                )
+            })
+        });
+
+        cx.run_until_parked();
+        (message_editor, cx)
+    }
+
+    #[gpui::test]
+    async fn test_set_message_plain_text(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "hello world".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        let text = message_editor.update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(text, "hello world");
+        assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx)));
+    }
+
+    #[gpui::test]
+    async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        // Set initial content.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "old content".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        // Replace with new content.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "new content".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        let text = message_editor.update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(
+            text, "new content",
+            "set_message should replace old content"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.append_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "appended".to_string(),
+                ))],
+                Some("\n\n"),
+                window,
+                cx,
+            );
+        });
+
+        let text = message_editor.update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(
+            text, "appended",
+            "No separator should be inserted when the editor is empty"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        // Seed initial content.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "initial".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        // Append with separator.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.append_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "appended".to_string(),
+                ))],
+                Some("\n\n"),
+                window,
+                cx,
+            );
+        });
+
+        let text = message_editor.update(cx, |editor, cx| editor.text(cx));
+        assert_eq!(
+            text, "initial\n\nappended",
+            "Separator should appear between existing and appended content"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project", json!({"file.txt": "content"}))
+            .await;
+        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+        let history =
+            cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+
+        let message_editor = cx.update(|window, cx| {
+            cx.new(|cx| {
+                MessageEditor::new(
+                    workspace.downgrade(),
+                    project.downgrade(),
+                    None,
+                    history.downgrade(),
+                    None,
+                    Default::default(),
+                    Default::default(),
+                    "Test Agent".into(),
+                    "Test",
+                    EditorMode::AutoHeight {
+                        min_lines: 1,
+                        max_lines: None,
+                    },
+                    window,
+                    cx,
+                )
+            })
+        });
+
+        cx.run_until_parked();
+
+        // Seed plain-text prefix so the editor is non-empty before appending.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_message(
+                vec![acp::ContentBlock::Text(acp::TextContent::new(
+                    "prefix text".to_string(),
+                ))],
+                window,
+                cx,
+            );
+        });
+
+        // Append a message that contains a ResourceLink mention.
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.append_message(
+                vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+                    "file.txt",
+                    "file:///project/file.txt",
+                ))],
+                Some("\n\n"),
+                window,
+                cx,
+            );
+        });
+
+        cx.run_until_parked();
+
+        // The mention should be registered in the mention_set so that contents()
+        // will emit it as a structured block rather than plain text.
+        let mention_uris =
+            message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
+        assert_eq!(
+            mention_uris.len(),
+            1,
+            "Expected exactly one mention in the mention_set after append, got: {mention_uris:?}"
+        );
+
+        // The editor text should start with the prefix, then the separator, then
+        // the mention placeholder — confirming the offset was computed correctly.
+        let text = message_editor.update(cx, |editor, cx| editor.text(cx));
+        assert!(
+            text.starts_with("prefix text\n\n"),
+            "Expected text to start with 'prefix text\\n\\n', got: {text:?}"
+        );
+    }
 }

crates/icons/src/icons.rs 🔗

@@ -191,6 +191,7 @@ pub enum IconName {
     Power,
     Public,
     PullRequest,
+    QueueMessage,
     Quote,
     Reader,
     RefreshTitle,