agent_ui: Make it easier to interrupt with message in queue (#46954)

Danilo Leal created

Follow up to https://github.com/zed-industries/zed/pull/46797

When you send a message, as per the PR linked above, Zed will now queue
it by default. But if you want to fast-track it/interrupt the agent
immediately, effectively sending the first message in the queue, you can
just hit enter again and it will get sent right away.


https://github.com/user-attachments/assets/5e6230f6-c56e-4496-9bcb-8d6ffb9e19cb

Release Notes:

- Agent: Made it easier to interrupt the agent while having messages in
the queue.

Change summary

assets/keymaps/default-linux.json         |  1 
assets/keymaps/default-macos.json         |  1 
assets/keymaps/default-windows.json       |  1 
crates/agent_ui/src/acp/message_editor.rs |  9 +-
crates/agent_ui/src/acp/thread_view.rs    | 90 ++++++++++++++++++++-----
crates/agent_ui/src/agent_ui.rs           |  2 
6 files changed, 81 insertions(+), 23 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

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

assets/keymaps/default-macos.json 🔗

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

assets/keymaps/default-windows.json 🔗

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

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

@@ -499,12 +499,11 @@ impl MessageEditor {
     }
 
     pub fn send(&mut self, cx: &mut Context<Self>) {
-        if self.is_empty(cx) {
-            return;
+        if !self.is_empty(cx) {
+            self.editor.update(cx, |editor, cx| {
+                editor.clear_inlay_hints(cx);
+            });
         }
-        self.editor.update(cx, |editor, cx| {
-            editor.clear_inlay_hints(cx);
-        });
         cx.emit(MessageEditorEvent::Send)
     }
 

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

@@ -71,8 +71,8 @@ use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
     AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
     CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
-    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, SelectPermissionGranularity,
-    SendImmediately, SendNextQueuedMessage, ToggleProfileSelector,
+    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage,
+    SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage, ToggleProfileSelector,
 };
 
 const MAX_COLLAPSED_LINES: usize = 3;
@@ -358,6 +358,7 @@ pub struct AcpThreadView {
     message_queue: Vec<QueuedMessage>,
     skip_queue_processing_count: usize,
     user_interrupted_generation: bool,
+    can_fast_track_queue: bool,
     turn_tokens: Option<u64>,
     last_turn_tokens: Option<u64>,
     turn_started_at: Option<Instant>,
@@ -553,6 +554,7 @@ impl AcpThreadView {
             message_queue: Vec::new(),
             skip_queue_processing_count: 0,
             user_interrupted_generation: false,
+            can_fast_track_queue: false,
             turn_tokens: None,
             last_turn_tokens: None,
             turn_started_at: None,
@@ -1007,6 +1009,7 @@ impl AcpThreadView {
     pub fn cancel_generation(&mut self, cx: &mut Context<Self>) {
         self.thread_error.take();
         self.thread_retry_status.take();
+        self.user_interrupted_generation = true;
 
         if let Some(thread) = self.thread() {
             self._cancel_task = Some(thread.update(cx, |thread, cx| thread.cancel(cx)));
@@ -1311,7 +1314,26 @@ impl AcpThreadView {
             return;
         }
 
-        if thread.read(cx).status() != ThreadStatus::Idle {
+        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)
+        if is_editor_empty
+            && is_generating
+            && self.can_fast_track_queue
+            && !self.message_queue.is_empty()
+        {
+            self.can_fast_track_queue = false;
+            self.send_queued_message_at_index(0, true, window, cx);
+            return;
+        }
+
+        if is_editor_empty {
+            return;
+        }
+
+        if is_generating {
             self.queue_message(window, cx);
             return;
         }
@@ -1626,6 +1648,8 @@ impl AcpThreadView {
                     content,
                     tracked_buffers,
                 });
+                // Enable fast-track: user can press Enter again to send this queued message immediately
+                this.can_fast_track_queue = true;
                 message_editor.update(cx, |message_editor, cx| {
                     message_editor.clear(window, cx);
                 });
@@ -1656,13 +1680,14 @@ impl AcpThreadView {
         };
 
         // Only increment skip count for "Send Now" operations (out-of-order sends)
-        // Normal auto-processing from the Stopped handler doesn't need to skip
+        // Normal auto-processing from the Stopped handler doesn't need to skip.
+        // We only skip the Stopped event from the cancelled generation, NOT the
+        // Stopped event from the newly sent message (which should trigger queue processing).
         if is_send_now {
             let is_generating = thread.read(cx).status() == acp_thread::ThreadStatus::Generating;
-            self.skip_queue_processing_count += if is_generating { 2 } else { 1 };
+            self.skip_queue_processing_count += if is_generating { 1 } else { 0 };
         }
 
-        // Ensure we don't end up with multiple concurrent generations
         let cancelled = thread.update(cx, |thread, cx| thread.cancel(cx));
 
         let should_be_following = self.should_be_following;
@@ -5783,6 +5808,7 @@ impl AcpThreadView {
                     .key_binding(KeyBinding::for_action(&ClearMessageQueue, cx))
                     .on_click(cx.listener(|this, _, _, cx| {
                         this.message_queue.clear();
+                        this.can_fast_track_queue = false;
                         cx.notify();
                     })),
             )
@@ -5795,6 +5821,7 @@ impl AcpThreadView {
     ) -> impl IntoElement {
         let message_editor = self.message_editor.read(cx);
         let focus_handle = message_editor.focus_handle(cx);
+        let can_fast_track = self.can_fast_track_queue && !self.message_queue.is_empty();
 
         v_flex()
             .id("message_queue_list")
@@ -5857,10 +5884,21 @@ impl AcpThreadView {
                                 h_flex()
                                     .flex_none()
                                     .gap_1()
-                                    .visible_on_hover("queue_entry")
+                                    .when(!is_next, |this| this.visible_on_hover("queue_entry"))
                                     .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,
+                                                        &focus_handle,
+                                                        cx,
+                                                    )
+                                                    .map(|kb| kb.size(rems_from_px(10.))),
+                                                )
+                                            })
                                             .on_click(cx.listener(move |this, _, _, cx| {
                                                 if index < this.message_queue.len() {
                                                     this.message_queue.remove(index);
@@ -5870,12 +5908,18 @@ impl AcpThreadView {
                                     )
                                     .child(
                                         Button::new(("send_now", index), "Send Now")
-                                            .style(ButtonStyle::Outlined)
                                             .label_size(LabelSize::Small)
                                             .when(is_next, |this| {
-                                                this.key_binding(
+                                                let action: Box<dyn gpui::Action> =
+                                                    if can_fast_track {
+                                                        Box::new(Chat)
+                                                    } else {
+                                                        Box::new(SendNextQueuedMessage)
+                                                    };
+
+                                                this.style(ButtonStyle::Outlined).key_binding(
                                                     KeyBinding::for_action_in(
-                                                        &SendNextQueuedMessage,
+                                                        action.as_ref(),
                                                         &focus_handle.clone(),
                                                         cx,
                                                     )
@@ -7824,8 +7868,15 @@ impl Render for AcpThreadView {
             .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
                 this.send_queued_message_at_index(0, true, window, cx);
             }))
+            .on_action(cx.listener(|this, _: &RemoveFirstQueuedMessage, _, cx| {
+                if !this.message_queue.is_empty() {
+                    this.message_queue.remove(0);
+                    cx.notify();
+                }
+            }))
             .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
                 this.message_queue.clear();
+                this.can_fast_track_queue = false;
                 cx.notify();
             }))
             .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
@@ -9099,20 +9150,23 @@ pub(crate) mod tests {
         add_to_workspace(thread_view.clone(), cx);
 
         let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone());
-        let mut events = cx.events(&message_editor);
         message_editor.update_in(cx, |editor, window, cx| {
             editor.set_text("", window, cx);
         });
 
-        message_editor.update_in(cx, |_editor, window, cx| {
-            window.dispatch_action(Box::new(Chat), cx);
+        let thread = cx.read(|cx| thread_view.read(cx).thread().cloned().unwrap());
+        let entries_before = cx.read(|cx| thread.read(cx).entries().len());
+
+        thread_view.update_in(cx, |view, window, cx| {
+            view.send(window, cx);
         });
         cx.run_until_parked();
-        // We shouldn't have received any messages
-        assert!(matches!(
-            events.try_next(),
-            Err(futures::channel::mpsc::TryRecvError { .. })
-        ));
+
+        let entries_after = cx.read(|cx| thread.read(cx).entries().len());
+        assert_eq!(
+            entries_before, entries_after,
+            "No message should be sent when editor is empty"
+        );
     }
 
     #[gpui::test]

crates/agent_ui/src/agent_ui.rs 🔗

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