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.
This commit is contained in:
Danilo Leal authored and GitHub committed 2026-01-15 20:07:16 -03:00
1 parent 24b7e3edb5
commit d183902dc7
6 files changed
+81 -23

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+4 -5
View File
@@ -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)
}
+72 -18
View File
@@ -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]
+2
View File
@@ -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.