From d183902dc7d8e55c58a85bbc3e2f0607b6571e82 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:07:16 -0300 Subject: [PATCH] agent_ui: Make it easier to interrupt with message in queue (#46954) 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. --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index e4dbb077e41eec089be232d2b4f2cf1e67318070..5d981f07f818021526ce4913486fc56ee2f9494e 100644 --- a/assets/keymaps/default-linux.json +++ b/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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 92b321c933bb80563ab0ddd5495c4491e7c73d54..6e1c42189c8c38f157a72e0ffb2f372eaa0904d2 100644 --- a/assets/keymaps/default-macos.json +++ b/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", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 519d5111054dcfbd6c3de37fc900c0cada897caa..fc5cb1240ce3c0999a2560cab5cbaded7c21b7af 100644 --- a/assets/keymaps/default-windows.json +++ b/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", diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 52c56035294a801272ef19f32426f3b0bdd2a350..e37baaf07d464ea6a24541fd989e558d481f545d 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -499,12 +499,11 @@ impl MessageEditor { } pub fn send(&mut self, cx: &mut Context) { - 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) } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 809b6c8cb26ca4767ad462277c71ed9815e0fd44..d135bbd809d860e63f6e872b4afe63b175fd6ac5 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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, skip_queue_processing_count: usize, user_interrupted_generation: bool, + can_fast_track_queue: bool, turn_tokens: Option, last_turn_tokens: Option, turn_started_at: Option, @@ -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.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 = + 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] diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 491bf8036325afaf01bde8f0cdac3a98755701c6..6d8648c9c418b2eda775c671b64a28eb6937dc10 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/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.