agent_ui: Add support for editing queued messages (#47234)

Danilo Leal created

This PR adds the ability to edit a queued message, which you can now do
by hitting `cmd-e` from the message editor, which will focus the first
queued message. To pull that off, I'm also making the queued messages
render as an editor, the same way we do with regular user messages. That
way, we ensure less layout shift when focusing in and out of the queued
message for editing and gain the ability to render context
buttons/creases the same way we do in the main message editor.


https://github.com/user-attachments/assets/fb68fd48-c0cd-491f-a7d9-5065a9151b0b

Note that in the video, I show the state in which you're still editing
in the moment in which the queued message would be sent. If that
happens, your queued message won't be sent even if you unfocus the
queued message editor. In this case, you need to explicitly hit "Send
Now".

Release Notes:

- Agent: Added the ability to edit queued messages.

Change summary

assets/keymaps/default-linux.json      |   1 
assets/keymaps/default-macos.json      |   1 
assets/keymaps/default-windows.json    |   1 
crates/agent/src/thread.rs             |  17 +
crates/agent_ui/src/acp/thread_view.rs | 373 ++++++++++++++++++++-------
crates/agent_ui/src/agent_ui.rs        |   2 
6 files changed, 301 insertions(+), 94 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -310,6 +310,7 @@
       "ctrl-shift-enter": "agent::SendImmediately",
       "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage",
       "shift-backspace": "agent::RemoveFirstQueuedMessage",
+      "shift-e": "agent::EditFirstQueuedMessage",
       "ctrl-shift-backspace": "agent::ClearMessageQueue",
       "ctrl-shift-v": "agent::PasteRaw",
       "ctrl-i": "agent::ToggleProfileSelector",

assets/keymaps/default-macos.json 🔗

@@ -358,6 +358,7 @@
       "cmd-shift-enter": "agent::SendImmediately",
       "cmd-shift-alt-enter": "agent::SendNextQueuedMessage",
       "shift-backspace": "agent::RemoveFirstQueuedMessage",
+      "shift-e": "agent::EditFirstQueuedMessage",
       "cmd-shift-backspace": "agent::ClearMessageQueue",
       "cmd-shift-v": "agent::PasteRaw",
       "cmd-i": "agent::ToggleProfileSelector",

assets/keymaps/default-windows.json 🔗

@@ -312,6 +312,7 @@
       "ctrl-shift-enter": "agent::SendImmediately",
       "ctrl-shift-alt-enter": "agent::SendNextQueuedMessage",
       "shift-backspace": "agent::RemoveFirstQueuedMessage",
+      "shift-e": "agent::EditFirstQueuedMessage",
       "ctrl-shift-backspace": "agent::ClearMessageQueue",
       "ctrl-shift-v": "agent::PasteRaw",
       "ctrl-i": "agent::ToggleProfileSelector",

crates/agent/src/thread.rs 🔗

@@ -1288,6 +1288,23 @@ impl Thread {
         }
     }
 
+    pub fn update_queued_message(
+        &mut self,
+        index: usize,
+        content: Vec<acp::ContentBlock>,
+        tracked_buffers: Vec<Entity<Buffer>>,
+    ) -> bool {
+        if index < self.queued_messages.len() {
+            self.queued_messages[index] = QueuedMessage {
+                content,
+                tracked_buffers,
+            };
+            true
+        } else {
+            false
+        }
+    }
+
     pub fn clear_queued_messages(&mut self) {
         self.queued_messages.clear();
     }

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

@@ -71,9 +71,10 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
 use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
     AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
-    CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
-    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
-    SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage, ToggleProfileSelector,
+    CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor, Follow,
+    KeepAll, NewThread, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+    RemoveFirstQueuedMessage, SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage,
+    ToggleProfileSelector,
 };
 
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
@@ -340,6 +341,9 @@ pub struct AcpThreadView {
     editor_expanded: bool,
     should_be_following: bool,
     editing_message: Option<usize>,
+    queued_message_editors: Vec<Entity<MessageEditor>>,
+    queued_message_editor_subscriptions: Vec<Subscription>,
+    last_synced_queue_length: usize,
     discarded_partial_edits: HashSet<acp::ToolCallId>,
     prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
     available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
@@ -519,6 +523,9 @@ impl AcpThreadView {
             expanded_subagents: HashSet::default(),
             subagent_scroll_handles: RefCell::new(HashMap::default()),
             editing_message: None,
+            queued_message_editors: Vec::new(),
+            queued_message_editor_subscriptions: Vec::new(),
+            last_synced_queue_length: 0,
             edits_expanded: false,
             plan_expanded: false,
             queue_expanded: true,
@@ -1318,12 +1325,10 @@ impl AcpThreadView {
         let is_editor_empty = self.message_editor.read(cx).is_empty(cx);
         let is_generating = thread.read(cx).status() != ThreadStatus::Idle;
 
-        // Fast-track: if editor is empty, we're generating, and user can fast-track,
-        // send the first queued message immediately (interrupting current generation)
         let has_queued = self
             .as_native_thread(cx)
             .is_some_and(|t| !t.read(cx).queued_messages().is_empty());
-        if is_editor_empty && is_generating && self.can_fast_track_queue && has_queued {
+        if is_editor_empty && self.can_fast_track_queue && has_queued {
             self.can_fast_track_queue = false;
             self.send_queued_message_at_index(0, true, window, cx);
             return;
@@ -1923,7 +1928,12 @@ impl AcpThreadView {
                     let has_queued = self
                         .as_native_thread(cx)
                         .is_some_and(|t| !t.read(cx).queued_messages().is_empty());
-                    if has_queued {
+                    // Don't auto-send if the first message editor is currently focused
+                    let is_first_editor_focused = self
+                        .queued_message_editors
+                        .first()
+                        .is_some_and(|editor| editor.focus_handle(cx).is_focused(window));
+                    if has_queued && !is_first_editor_focused {
                         self.send_queued_message_at_index(0, false, window, cx);
                     }
                 }
@@ -5737,18 +5747,7 @@ impl AcpThreadView {
         let message_editor = self.message_editor.read(cx);
         let focus_handle = message_editor.focus_handle(cx);
 
-        let queued_messages: Vec<_> = self
-            .as_native_thread(cx)
-            .map(|t| {
-                t.read(cx)
-                    .queued_messages()
-                    .iter()
-                    .map(|q| q.content.clone())
-                    .collect()
-            })
-            .unwrap_or_default();
-
-        let queue_len = queued_messages.len();
+        let queue_len = self.queued_message_editors.len();
         let can_fast_track = self.can_fast_track_queue && queue_len > 0;
 
         v_flex()
@@ -5756,101 +5755,156 @@ impl AcpThreadView {
             .max_h_40()
             .overflow_y_scroll()
             .children(
-                queued_messages
-                    .into_iter()
+                self.queued_message_editors
+                    .iter()
                     .enumerate()
-                    .map(|(index, content)| {
+                    .map(|(index, editor)| {
                         let is_next = index == 0;
-                        let icon_color = if is_next { Color::Accent } else { Color::Muted };
+                        let (icon_color, tooltip_text) = if is_next {
+                            (Color::Accent, "Next in Queue")
+                        } else {
+                            (Color::Muted, "In Queue")
+                        };
 
-                        let preview: String = content
-                            .iter()
-                            .filter_map(|block| match block {
-                                acp::ContentBlock::Text(text) => {
-                                    let first_line = text.text.lines().next()?;
-                                    if first_line.is_empty() {
-                                        None
-                                    } else {
-                                        Some(first_line.to_owned())
-                                    }
-                                }
-                                acp::ContentBlock::Image(_) => Some("@Image".to_owned()),
-                                acp::ContentBlock::Audio(_) => Some("@Audio".to_owned()),
-                                acp::ContentBlock::ResourceLink(link) => {
-                                    let name = link.uri.rsplit('/').next().unwrap_or(&link.uri);
-                                    Some(format!("@{}", name))
-                                }
-                                acp::ContentBlock::Resource(resource) => {
-                                    let uri = match &resource.resource {
-                                        acp::EmbeddedResourceResource::TextResourceContents(r) => {
-                                            Some(&r.uri)
-                                        }
-                                        acp::EmbeddedResourceResource::BlobResourceContents(r) => {
-                                            Some(&r.uri)
-                                        }
-                                        _ => None,
-                                    };
-                                    uri.map(|uri| {
-                                        let name = uri.rsplit('/').next().unwrap_or(uri);
-                                        format!("@{}", name)
-                                    })
-                                }
-                                _ => None,
-                            })
-                            .collect::<Vec<_>>()
-                            .join("");
+                        let editor_focused = editor.focus_handle(cx).is_focused(_window);
+                        let keybinding_size = rems_from_px(12.);
 
                         h_flex()
                             .group("queue_entry")
                             .w_full()
-                            .p_1()
-                            .pl_2()
+                            .p_1p5()
                             .gap_1()
-                            .justify_between()
                             .bg(cx.theme().colors().editor_background)
-                            .when(index < queue_len - 1, |parent| {
-                                parent.border_color(cx.theme().colors().border).border_b_1()
+                            .when(index < queue_len - 1, |this| {
+                                this.border_b_1()
+                                    .border_color(cx.theme().colors().border_variant)
                             })
                             .child(
-                                h_flex()
-                                    .id(("queued_prompt", index))
-                                    .min_w_0()
-                                    .w_full()
-                                    .gap_1p5()
+                                div()
+                                    .id("next_in_queue")
                                     .child(
                                         Icon::new(IconName::Circle)
                                             .size(IconSize::Small)
                                             .color(icon_color),
                                     )
-                                    .child(
-                                        Label::new(preview)
-                                            .size(LabelSize::XSmall)
-                                            .color(Color::Muted)
-                                            .buffer_font(cx)
-                                            .truncate(),
-                                    )
-                                    .when(is_next, |this| {
-                                        this.tooltip(Tooltip::text("Next Prompt in the Queue"))
-                                    }),
+                                    .tooltip(Tooltip::text(tooltip_text)),
                             )
-                            .child(
+                            .child(editor.clone())
+                            .child(if editor_focused {
                                 h_flex()
-                                    .flex_none()
                                     .gap_1()
-                                    .when(!is_next, |this| this.visible_on_hover("queue_entry"))
+                                    .min_w_40()
                                     .child(
-                                        Button::new(("delete", index), "Remove")
-                                            .label_size(LabelSize::Small)
-                                            .tooltip(Tooltip::text("Remove Message from Queue"))
-                                            .when(is_next, |this| {
-                                                this.key_binding(
-                                                    KeyBinding::for_action_in(
-                                                        &RemoveFirstQueuedMessage,
+                                        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,
                                                     )
-                                                    .map(|kb| kb.size(rems_from_px(10.))),
+                                                }
+                                            })
+                                            .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)
+                                            .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,
+                                                    )
+                                                }
+                                            })
+                                            .on_click({
+                                                let main_editor = self.message_editor.clone();
+                                                cx.listener(move |_, _, window, cx| {
+                                                    window.focus(&main_editor.focus_handle(cx), cx);
+                                                })
+                                            }),
+                                    )
+                                    .child(
+                                        Button::new(("send_now_focused", index), "Send Now")
+                                            .label_size(LabelSize::Small)
+                                            .style(ButtonStyle::Outlined)
+                                            .key_binding(
+                                                KeyBinding::for_action_in(
+                                                    &SendImmediately,
+                                                    &editor.focus_handle(cx),
+                                                    cx,
                                                 )
+                                                .map(|kb| kb.size(keybinding_size)),
+                                            )
+                                            .on_click(cx.listener(move |this, _, window, cx| {
+                                                this.send_queued_message_at_index(
+                                                    index, true, window, cx,
+                                                );
+                                            })),
+                                    )
+                            } else {
+                                h_flex()
+                                    .gap_1()
+                                    .when(!is_next, |this| this.visible_on_hover("queue_entry"))
+                                    .child(
+                                        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(
+                                                            "Edit",
+                                                            &EditFirstQueuedMessage,
+                                                            &focus_handle,
+                                                            cx,
+                                                        )
+                                                    } else {
+                                                        Tooltip::simple("Edit", cx)
+                                                    }
+                                                }
+                                            })
+                                            .on_click({
+                                                let editor = editor.clone();
+                                                cx.listener(move |_, _, window, cx| {
+                                                    window.focus(&editor.focus_handle(cx), cx);
+                                                })
+                                            }),
+                                    )
+                                    .child(
+                                        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(
+                                                            "Remove Message from Queue",
+                                                            &RemoveFirstQueuedMessage,
+                                                            &focus_handle,
+                                                            cx,
+                                                        )
+                                                    } else {
+                                                        Tooltip::simple(
+                                                            "Remove Message from Queue",
+                                                            cx,
+                                                        )
+                                                    }
+                                                }
                                             })
                                             .on_click(cx.listener(move |this, _, _, cx| {
                                                 if let Some(thread) = this.as_native_thread(cx) {
@@ -5864,7 +5918,7 @@ impl AcpThreadView {
                                     .child(
                                         Button::new(("send_now", index), "Send Now")
                                             .label_size(LabelSize::Small)
-                                            .when(is_next, |this| {
+                                            .when(is_next && message_editor.is_empty(cx), |this| {
                                                 let action: Box<dyn gpui::Action> =
                                                     if can_fast_track {
                                                         Box::new(Chat)
@@ -5878,16 +5932,19 @@ impl AcpThreadView {
                                                         &focus_handle.clone(),
                                                         cx,
                                                     )
-                                                    .map(|kb| kb.size(rems_from_px(10.))),
+                                                    .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,
                                                 );
                                             })),
-                                    ),
-                            )
+                                    )
+                            })
                     }),
             )
             .into_any_element()
@@ -6008,6 +6065,127 @@ impl AcpThreadView {
             .thread(acp_thread.session_id(), cx)
     }
 
+    fn save_queued_message_at_index(&mut self, index: usize, cx: &mut Context<Self>) {
+        let Some(editor) = self.queued_message_editors.get(index) else {
+            return;
+        };
+
+        let Some(_native_thread) = self.as_native_thread(cx) else {
+            return;
+        };
+
+        let contents_task = editor.update(cx, |editor, cx| editor.contents(false, cx));
+
+        cx.spawn(async move |this, cx| {
+            let Ok((content, tracked_buffers)) = contents_task.await else {
+                return Ok::<(), anyhow::Error>(());
+            };
+
+            this.update(cx, |this, cx| {
+                if let Some(native_thread) = this.as_native_thread(cx) {
+                    native_thread.update(cx, |thread, _| {
+                        thread.update_queued_message(index, content, tracked_buffers);
+                    });
+                }
+                cx.notify();
+            })?;
+
+            Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn sync_queued_message_editors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(native_thread) = self.as_native_thread(cx) else {
+            self.queued_message_editors.clear();
+            self.queued_message_editor_subscriptions.clear();
+            self.last_synced_queue_length = 0;
+            return;
+        };
+
+        let thread = native_thread.read(cx);
+        let needed_count = thread.queued_messages().len();
+        let current_count = self.queued_message_editors.len();
+
+        if current_count == needed_count && needed_count == self.last_synced_queue_length {
+            return;
+        }
+
+        let queued_messages: Vec<_> = thread
+            .queued_messages()
+            .iter()
+            .map(|q| q.content.clone())
+            .collect();
+
+        if current_count > needed_count {
+            self.queued_message_editors.truncate(needed_count);
+            self.queued_message_editor_subscriptions
+                .truncate(needed_count);
+
+            for (index, editor) in self.queued_message_editors.iter().enumerate() {
+                if let Some(content) = queued_messages.get(index) {
+                    editor.update(cx, |editor, cx| {
+                        editor.set_message(content.clone(), window, cx);
+                    });
+                }
+            }
+        }
+
+        while self.queued_message_editors.len() < needed_count {
+            let agent_name = self.agent.name();
+            let index = self.queued_message_editors.len();
+            let content = queued_messages.get(index).cloned().unwrap_or_default();
+
+            let editor = cx.new(|cx| {
+                let mut editor = MessageEditor::new(
+                    self.workspace.clone(),
+                    self.project.downgrade(),
+                    None,
+                    self.history.downgrade(),
+                    None,
+                    self.prompt_capabilities.clone(),
+                    self.available_commands.clone(),
+                    agent_name.clone(),
+                    "",
+                    EditorMode::AutoHeight {
+                        min_lines: 1,
+                        max_lines: Some(10),
+                    },
+                    window,
+                    cx,
+                );
+                editor.set_message(content, window, cx);
+                editor
+            });
+
+            let main_editor = self.message_editor.clone();
+            let subscription = cx.subscribe_in(
+                &editor,
+                window,
+                move |this, _editor, event, window, cx| match event {
+                    MessageEditorEvent::LostFocus => {
+                        this.save_queued_message_at_index(index, cx);
+                    }
+                    MessageEditorEvent::Cancel => {
+                        window.focus(&main_editor.focus_handle(cx), cx);
+                    }
+                    MessageEditorEvent::Send => {
+                        window.focus(&main_editor.focus_handle(cx), cx);
+                    }
+                    MessageEditorEvent::SendImmediately => {
+                        this.send_queued_message_at_index(index, true, window, cx);
+                    }
+                    _ => {}
+                },
+            );
+
+            self.queued_message_editors.push(editor);
+            self.queued_message_editor_subscriptions.push(subscription);
+        }
+
+        self.last_synced_queue_length = needed_count;
+    }
+
     fn is_imported_thread(&self, cx: &App) -> bool {
         let Some(thread) = self.as_native_thread(cx) else {
             return false;
@@ -7780,6 +7958,8 @@ impl AcpThreadView {
 
 impl Render for AcpThreadView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        self.sync_queued_message_editors(window, cx);
+
         let has_messages = self.list_state.item_count() > 0;
 
         v_flex()
@@ -7807,6 +7987,11 @@ impl Render for AcpThreadView {
                     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);
+                }
+            }))
             .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
                 if let Some(thread) = this.as_native_thread(cx) {
                     thread.update(cx, |thread, _| thread.clear_queued_messages());

crates/agent_ui/src/agent_ui.rs 🔗

@@ -128,6 +128,8 @@ actions!(
         SendNextQueuedMessage,
         /// Removes the first message from the queue (the next one to be sent).
         RemoveFirstQueuedMessage,
+        /// Edits the first message in the queue (the next one to be sent).
+        EditFirstQueuedMessage,
         /// Clears all messages from the queue.
         ClearMessageQueue,
         /// Opens the permission granularity dropdown for the current tool call.