From 0cf551fd6f45a4bfe4d4e6deaade8017c59d9687 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Mon, 2 Mar 2026 13:02:30 -0300
Subject: [PATCH] agent_ui: Use main message editor for editing queued messages
(#50490)
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.
---
assets/icons/queue_message.svg | 7 +
crates/agent_ui/src/connection_view.rs | 100 ++++++
.../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(-)
create mode 100644 assets/icons/queue_message.svg
diff --git a/assets/icons/queue_message.svg b/assets/icons/queue_message.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1bdf6738bcf3143fc13a820281cf1cab8531bd36
--- /dev/null
+++ b/assets/icons/queue_message.svg
@@ -0,0 +1,7 @@
+
diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs
index a3a62459a2e98680b3910877cc9cd1e6e58ba056..93bf7c98098530b23522c60f987f9e341ebc69ca 100644
--- a/crates/agent_ui/src/connection_view.rs
+++ b/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,
+ ) {
+ 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,
@@ -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"
+ );
+ }
}
diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs
index 0915911104b50ffe9d464a0552d3e43b0794b34d..b8403f8052e32fbeeceb4594438eecf32aa4e2e7 100644
--- a/crates/agent_ui/src/connection_view/thread_view.rs
+++ b/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,
+ ) -> 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 =
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();
diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs
index 071142f083bc94b5d057a366d124d11e7822d1fd..aef7f1f335eff7d092f924b9883ab0d64bbf65a8 100644
--- a/crates/agent_ui/src/entry_view_state.rs
+++ b/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();
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index a24a5f5f65dae3f8bbce7d0a7b7f4988a1bd5e38..274b076eafbcfab4620c66c027c374025242f821 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/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),
}
impl EventEmitter 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,
window: &mut Window,
cx: &mut Context,
+ ) {
+ self.clear(window, cx);
+ self.insert_message_blocks(message, false, window, cx);
+ }
+
+ pub fn append_message(
+ &mut self,
+ message: Vec,
+ separator: Option<&str>,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ 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,
+ append_to_existing: bool,
+ window: &mut Window,
+ cx: &mut Context,
) {
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) {
+ 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, &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:?}"
+ );
+ }
}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index a8a4e47cd0046fa995b10bb5e91b8884d70cdd6d..73db39afdc5e9bd15f084043370d27f0494569a6 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -191,6 +191,7 @@ pub enum IconName {
Power,
Public,
PullRequest,
+ QueueMessage,
Quote,
Reader,
RefreshTitle,